From 55aeb58a83a235e32fe080d55464dfed74f158f1 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 24 Aug 2025 17:56:56 +0900 Subject: [PATCH 001/100] =?UTF-8?q?feat:=20=EC=B9=9C=EA=B5=AC=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20Service=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alarmy/near/data/mapper/FriendMapper.kt | 8 ++-- .../near/network/request/FriendRequest.kt | 27 ++++++++++++++ .../network/response/CommonMessageEntity.kt | 8 ++++ .../near/network/response/FriendEntity.kt | 27 +++++++++++--- .../network/response/FriendRecordEntity.kt | 6 +++ .../network/response/FriendSummaryEntity.kt | 15 ++++++++ .../near/network/service/FriendService.kt | 37 ++++++++++++++++++- 7 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/network/request/FriendRequest.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/network/response/CommonMessageEntity.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/network/response/FriendRecordEntity.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/network/response/FriendSummaryEntity.kt diff --git a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt index fcfc70c5..2ec9445c 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 @@ -2,9 +2,9 @@ package com.alarmy.near.data.mapper import com.alarmy.near.model.ContactFrequency import com.alarmy.near.model.FriendSummary -import com.alarmy.near.network.response.FriendEntity +import com.alarmy.near.network.response.FriendSummaryEntity -fun FriendEntity.toModel(): FriendSummary = +fun FriendSummaryEntity.toModel(): FriendSummary = FriendSummary( id = friendId, name = name, @@ -17,5 +17,5 @@ fun FriendEntity.toModel(): FriendSummary = in 30..69 -> ContactFrequency.MIDDLE in 70..100 -> ContactFrequency.HIGH else -> ContactFrequency.LOW - }, - ) + }, + ) diff --git a/Near/app/src/main/java/com/alarmy/near/network/request/FriendRequest.kt b/Near/app/src/main/java/com/alarmy/near/network/request/FriendRequest.kt new file mode 100644 index 00000000..1aa1099e --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/request/FriendRequest.kt @@ -0,0 +1,27 @@ +package com.alarmy.near.network.request + +import kotlinx.serialization.Serializable + +@Serializable +data class FriendRequest( + val name: String, + val relation: String, + val contactFrequency: ContactFrequencyRequest, + val birthday: String?, + val anniversaryList: List, + val memo: String?, + val phone: String?, +) + +@Serializable +data class ContactFrequencyRequest( + val contactWeek: String, + val dayOfWeek: String, +) + +@Serializable +data class AnniversaryRequest( + val id: Int, + val title: String, + val date: String, +) diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/CommonMessageEntity.kt b/Near/app/src/main/java/com/alarmy/near/network/response/CommonMessageEntity.kt new file mode 100644 index 00000000..6566e2bf --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/response/CommonMessageEntity.kt @@ -0,0 +1,8 @@ +package com.alarmy.near.network.response + +import kotlinx.serialization.Serializable + +@Serializable +data class CommonMessageEntity( + val message: String, +) diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt b/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt index 7ec3ab3d..3862ebd7 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt @@ -5,11 +5,26 @@ import kotlinx.serialization.Serializable @Serializable data class FriendEntity( val friendId: String, - val position: Int, - val source: String, + val imageUrl: String, + val relation: String, val name: String, - val imageUrl: String? = null, - val fileName: String? = null, - val checkRate: Int, - val lastContactAt: String? = null, + val contactFrequencyEntity: ContactFrequencyEntity, + val birthday: String?, + val anniversaryEntityList: List, + val memo: String?, + val phone: String?, + val lastContactAt: String?, +) + +@Serializable +data class ContactFrequencyEntity( + val contactWeek: String, + val dayOfWeek: String, +) + +@Serializable +data class AnniversaryEntity( + val id: Int, + val title: String, + val date: String, ) diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/FriendRecordEntity.kt b/Near/app/src/main/java/com/alarmy/near/network/response/FriendRecordEntity.kt new file mode 100644 index 00000000..ccc9355b --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/response/FriendRecordEntity.kt @@ -0,0 +1,6 @@ +package com.alarmy.near.network.response + +data class FriendRecordEntity( + val isChecked: Boolean, + val createdAt: String, +) diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/FriendSummaryEntity.kt b/Near/app/src/main/java/com/alarmy/near/network/response/FriendSummaryEntity.kt new file mode 100644 index 00000000..f6a6c55b --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/response/FriendSummaryEntity.kt @@ -0,0 +1,15 @@ +package com.alarmy.near.network.response + +import kotlinx.serialization.Serializable + +@Serializable +data class FriendSummaryEntity( + val friendId: String, + val position: Int, + val source: String, + val name: String, + val imageUrl: String? = null, + val fileName: String? = null, + val checkRate: Int, + val lastContactAt: String? = null, +) diff --git a/Near/app/src/main/java/com/alarmy/near/network/service/FriendService.kt b/Near/app/src/main/java/com/alarmy/near/network/service/FriendService.kt index 0beca5dc..a07f12de 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/service/FriendService.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/service/FriendService.kt @@ -1,13 +1,48 @@ package com.alarmy.near.network.service +import androidx.room.Delete +import com.alarmy.near.network.request.FriendRequest +import com.alarmy.near.network.response.CommonMessageEntity import com.alarmy.near.network.response.FriendEntity +import com.alarmy.near.network.response.FriendRecordEntity +import com.alarmy.near.network.response.FriendSummaryEntity import com.alarmy.near.network.response.MonthlyFriendEntity +import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path interface FriendService { @GET("/friend/list") - suspend fun fetchFriends(): List + suspend fun fetchFriends(): List @GET("/friend/monthly") suspend fun fetchMonthlyFriends(): List + + @GET("/friend/{friendId}") + suspend fun fetchFriendById( + @Path("friendId") friendId: String, + ): FriendEntity + + @PUT("/friend/{friendId}") + suspend fun updateFriend( + @Path("friendId") friendId: String, + @Body friendRequest: FriendRequest, + ): FriendEntity + + @Delete + suspend fun deleteFriend( + @Path("friendId") friendId: String, + ) + + @GET("/friend/record/{friendId}") + suspend fun fetchFriendRecord( + @Path("friendId") friendId: String, + ): List + + @POST("/friend/record/{friendId}") + suspend fun recordContact( + @Path("friendId") friendId: String, + ): CommonMessageEntity } From 783ba6284eb35924a764aa437f7acc601436f009 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 24 Aug 2025 18:02:53 +0900 Subject: [PATCH 002/100] =?UTF-8?q?refactor:=20ContactFrequency=20->=20Con?= =?UTF-8?q?tactFrequencyLevel=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/alarmy/near/data/mapper/FriendMapper.kt | 12 ++++++------ ...{ContactFrequency.kt => ContactFrequencyLevel.kt} | 2 +- .../main/java/com/alarmy/near/model/FriendSummary.kt | 2 +- .../near/presentation/feature/home/HomeScreen.kt | 4 ++-- .../feature/home/component/ContactItem.kt | 12 ++++++------ .../feature/home/component/MyContacts.kt | 5 ++--- 6 files changed, 18 insertions(+), 19 deletions(-) rename Near/app/src/main/java/com/alarmy/near/model/{ContactFrequency.kt => ContactFrequencyLevel.kt} (64%) 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 2ec9445c..c6468ee0 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt @@ -1,6 +1,6 @@ package com.alarmy.near.data.mapper -import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.ContactFrequencyLevel import com.alarmy.near.model.FriendSummary import com.alarmy.near.network.response.FriendSummaryEntity @@ -11,11 +11,11 @@ fun FriendSummaryEntity.toModel(): FriendSummary = profileImageUrl = imageUrl, lastContactedAt = lastContactAt, isContacted = true, - contactFrequency = + contactFrequencyLevel = when (checkRate) { - in 0..29 -> ContactFrequency.LOW - in 30..69 -> ContactFrequency.MIDDLE - in 70..100 -> ContactFrequency.HIGH - else -> ContactFrequency.LOW + in 0..29 -> ContactFrequencyLevel.LOW + in 30..69 -> ContactFrequencyLevel.MIDDLE + in 70..100 -> ContactFrequencyLevel.HIGH + else -> ContactFrequencyLevel.LOW }, ) diff --git a/Near/app/src/main/java/com/alarmy/near/model/ContactFrequency.kt b/Near/app/src/main/java/com/alarmy/near/model/ContactFrequencyLevel.kt similarity index 64% rename from Near/app/src/main/java/com/alarmy/near/model/ContactFrequency.kt rename to Near/app/src/main/java/com/alarmy/near/model/ContactFrequencyLevel.kt index dfcea4a5..3d5c0df0 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/ContactFrequency.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/ContactFrequencyLevel.kt @@ -1,6 +1,6 @@ package com.alarmy.near.model -enum class ContactFrequency { +enum class ContactFrequencyLevel { LOW, MIDDLE, HIGH, diff --git a/Near/app/src/main/java/com/alarmy/near/model/FriendSummary.kt b/Near/app/src/main/java/com/alarmy/near/model/FriendSummary.kt index 49249003..b0fddf88 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/FriendSummary.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/FriendSummary.kt @@ -9,5 +9,5 @@ data class FriendSummary( val profileImageUrl: String?, val lastContactedAt: String?, val isContacted: Boolean, - val contactFrequency: ContactFrequency, + val contactFrequencyLevel: ContactFrequencyLevel, ) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeScreen.kt index 61612787..0f16dcaf 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 @@ -52,7 +52,7 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R -import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.ContactFrequencyLevel import com.alarmy.near.model.FriendSummary import com.alarmy.near.model.monthly.MonthlyFriend import com.alarmy.near.model.monthly.MonthlyFriendType @@ -388,7 +388,7 @@ internal fun HomeScreenPreview() { profileImageUrl = "https://search.yahoo.com/search?p=partiendo", lastContactedAt = "2025-07-16", isContacted = false, - contactFrequency = ContactFrequency.HIGH, + contactFrequencyLevel = ContactFrequencyLevel.HIGH, ) }, monthlyFriends = diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt index 15fd3d28..ed45d6a9 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.alarmy.near.R -import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.ContactFrequencyLevel import com.alarmy.near.model.FriendSummary import com.alarmy.near.presentation.ui.extension.onNoRippleClick import com.alarmy.near.presentation.ui.theme.NearTheme @@ -50,10 +50,10 @@ fun ContactItem( .align(Alignment.TopEnd) .offset(x = 4.dp, y = (-4).dp), painter = - when (friendSummary.contactFrequency) { - ContactFrequency.LOW -> painterResource(R.drawable.ic_visual_24_emoji_0) - ContactFrequency.MIDDLE -> painterResource(R.drawable.ic_visual_24_emoji_50) - ContactFrequency.HIGH -> painterResource(R.drawable.ic_visual_24_emoji_100) + when (friendSummary.contactFrequencyLevel) { + ContactFrequencyLevel.LOW -> painterResource(R.drawable.ic_visual_24_emoji_0) + ContactFrequencyLevel.MIDDLE -> painterResource(R.drawable.ic_visual_24_emoji_50) + ContactFrequencyLevel.HIGH -> painterResource(R.drawable.ic_visual_24_emoji_100) }, contentDescription = "", ) @@ -99,7 +99,7 @@ fun ContactItemPreview() { profileImageUrl = "", lastContactedAt = "2025-04-21", isContacted = true, - contactFrequency = ContactFrequency.HIGH, + contactFrequencyLevel = ContactFrequencyLevel.HIGH, ), ) } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt index c09f3796..1376552e 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt @@ -17,10 +17,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.ContactFrequencyLevel import com.alarmy.near.model.FriendSummary import com.alarmy.near.presentation.ui.theme.NearTheme -import java.time.LocalDate private const val OVERFLOW_WIDTH_OF_CONTACT_ITEM_BY_NAME_TEXT = 34 @@ -240,7 +239,7 @@ fun MyContactsPreview() { profileImageUrl = "https://search.yahoo.com/search?p=partiendo", lastContactedAt = "2025-04-21", isContacted = false, - contactFrequency = ContactFrequency.LOW, + contactFrequencyLevel = ContactFrequencyLevel.LOW, ) }.chunked(5), ) From 83815a3abe2c19a380a8cdee7eed8798c329098e Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 24 Aug 2025 18:09:30 +0900 Subject: [PATCH 003/100] =?UTF-8?q?feat:=20repository=20API=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alarmy/near/data/mapper/FriendMapper.kt | 73 +++++++++++++++---- .../near/data/mapper/FriendRecordMapper.kt | 10 +++ .../near/data/mapper/FriendSummaryMapper.kt | 21 ++++++ .../repository/DefaultFriendRepository.kt | 33 +++++++++ .../near/data/repository/FriendRepository.kt | 15 ++++ .../main/java/com/alarmy/near/model/Friend.kt | 25 +++++++ .../com/alarmy/near/model/FriendRecord.kt | 6 ++ 7 files changed, 167 insertions(+), 16 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/data/mapper/FriendRecordMapper.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/model/Friend.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/model/FriendRecord.kt 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 c6468ee0..0aab0634 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt @@ -1,21 +1,62 @@ package com.alarmy.near.data.mapper -import com.alarmy.near.model.ContactFrequencyLevel -import com.alarmy.near.model.FriendSummary -import com.alarmy.near.network.response.FriendSummaryEntity +import com.alarmy.near.model.Anniversary +import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.Friend +import com.alarmy.near.network.request.AnniversaryRequest +import com.alarmy.near.network.request.ContactFrequencyRequest +import com.alarmy.near.network.request.FriendRequest +import com.alarmy.near.network.response.AnniversaryEntity +import com.alarmy.near.network.response.ContactFrequencyEntity +import com.alarmy.near.network.response.FriendEntity -fun FriendSummaryEntity.toModel(): FriendSummary = - FriendSummary( - id = friendId, +fun FriendEntity.toModel(): Friend = + Friend( + friendId = friendId, + imageUrl = imageUrl, + relation = relation, name = name, - profileImageUrl = imageUrl, - lastContactedAt = lastContactAt, - isContacted = true, - contactFrequencyLevel = - when (checkRate) { - in 0..29 -> ContactFrequencyLevel.LOW - in 30..69 -> ContactFrequencyLevel.MIDDLE - in 70..100 -> ContactFrequencyLevel.HIGH - else -> ContactFrequencyLevel.LOW - }, + contactFrequency = contactFrequencyEntity.toModel(), + birthday = birthday, + anniversaryList = anniversaryEntityList.map { it.toModel() }, + memo = memo, + phone = phone, + lastContactAt = lastContactAt, + ) + +fun ContactFrequencyEntity.toModel(): ContactFrequency = + ContactFrequency( + contactWeek = contactWeek, + dayOfWeek = dayOfWeek, + ) + +fun AnniversaryEntity.toModel(): Anniversary = + Anniversary( + id = id, + title = title, + date = date, + ) + +fun Friend.toRequest(): FriendRequest = + FriendRequest( + name = name, + relation = relation, + contactFrequency = contactFrequency.toRequest(), + birthday = birthday, + anniversaryList = anniversaryList.map { it.toRequest() }, + memo = memo, + phone = phone, + ) + +fun ContactFrequency.toRequest(): ContactFrequencyRequest = + ContactFrequencyRequest( + contactWeek = contactWeek, + dayOfWeek = dayOfWeek, + ) + +fun Anniversary.toRequest(): AnniversaryRequest = + AnniversaryRequest( + id = id, + title = title, + date = date, ) diff --git a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendRecordMapper.kt b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendRecordMapper.kt new file mode 100644 index 00000000..343e65b9 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendRecordMapper.kt @@ -0,0 +1,10 @@ +package com.alarmy.near.data.mapper + +import com.alarmy.near.model.FriendRecord +import com.alarmy.near.network.response.FriendRecordEntity + +fun FriendRecordEntity.toModel(): FriendRecord = + FriendRecord( + isChecked = isChecked, + createdAt = createdAt, + ) diff --git a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt new file mode 100644 index 00000000..c6468ee0 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt @@ -0,0 +1,21 @@ +package com.alarmy.near.data.mapper + +import com.alarmy.near.model.ContactFrequencyLevel +import com.alarmy.near.model.FriendSummary +import com.alarmy.near.network.response.FriendSummaryEntity + +fun FriendSummaryEntity.toModel(): FriendSummary = + FriendSummary( + id = friendId, + name = name, + profileImageUrl = imageUrl, + lastContactedAt = lastContactAt, + isContacted = true, + contactFrequencyLevel = + when (checkRate) { + in 0..29 -> ContactFrequencyLevel.LOW + in 30..69 -> ContactFrequencyLevel.MIDDLE + in 70..100 -> ContactFrequencyLevel.HIGH + else -> ContactFrequencyLevel.LOW + }, + ) diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt index 61ae63e7..8449b415 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,6 +1,9 @@ package com.alarmy.near.data.repository 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 import com.alarmy.near.model.monthly.MonthlyFriend import com.alarmy.near.network.service.FriendService @@ -30,4 +33,34 @@ class DefaultFriendRepository }, ) } + + override fun fetchFriendById(friendId: String): Flow = + flow { + emit(friendService.fetchFriendById(friendId).toModel()) + } + + override fun updateFriend( + friendId: String, + friend: Friend, + ): Flow = + flow { + emit(friendService.updateFriend(friendId, friend.toRequest()).toModel()) + } + + override fun deleteFriend(friendId: String): Flow = + flow { + friendService.deleteFriend(friendId) + emit(Unit) + } + + override fun fetchFriendRecord(friendId: String): Flow> = + flow { + emit(friendService.fetchFriendRecord(friendId).map { it.toModel() }) + } + + override fun recordContact(friendId: String): Flow = + flow { + val response = friendService.recordContact(friendId) + emit(response.message) // CommonMessageEntity.message 라고 가정 + } } diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/FriendRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/FriendRepository.kt index 425654fe..15c97570 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/FriendRepository.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/FriendRepository.kt @@ -1,5 +1,7 @@ package com.alarmy.near.data.repository +import com.alarmy.near.model.Friend +import com.alarmy.near.model.FriendRecord import com.alarmy.near.model.FriendSummary import com.alarmy.near.model.monthly.MonthlyFriend import kotlinx.coroutines.flow.Flow @@ -8,4 +10,17 @@ interface FriendRepository { fun fetchFriends(): Flow> fun fetchMonthlyFriends(): Flow> + + fun fetchFriendById(friendId: String): Flow + + fun updateFriend( + friendId: String, + friend: Friend, + ): Flow + + fun deleteFriend(friendId: String): Flow + + fun fetchFriendRecord(friendId: String): Flow> + + fun recordContact(friendId: String): Flow } diff --git a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt new file mode 100644 index 00000000..1660f7ad --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt @@ -0,0 +1,25 @@ +package com.alarmy.near.model + +data class Friend( + val friendId: String, + val imageUrl: String, + val relation: String, + val name: String, + val contactFrequency: ContactFrequency, + val birthday: String?, + val anniversaryList: List, + val memo: String?, + val phone: String?, + val lastContactAt: String?, +) + +data class ContactFrequency( + val contactWeek: String, + val dayOfWeek: String, +) + +data class Anniversary( + val id: Int, + val title: String, + val date: String, +) diff --git a/Near/app/src/main/java/com/alarmy/near/model/FriendRecord.kt b/Near/app/src/main/java/com/alarmy/near/model/FriendRecord.kt new file mode 100644 index 00000000..a7754e22 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/model/FriendRecord.kt @@ -0,0 +1,6 @@ +package com.alarmy.near.model + +data class FriendRecord( + val isChecked: Boolean, + val createdAt: String, +) From 061043f3b0efa5a9dc74d3447c61fa067827a5af Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 24 Aug 2025 18:15:49 +0900 Subject: [PATCH 004/100] =?UTF-8?q?refactor:=20friendSummary=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EC=84=B8=EB=B6=80=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt | 4 ++-- .../alarmy/near/data/repository/DefaultFriendRepository.kt | 2 +- .../java/com/alarmy/near/data/repository/FriendRepository.kt | 2 +- .../near/model/{ => friendsummary}/ContactFrequencyLevel.kt | 2 +- .../alarmy/near/model/{ => friendsummary}/FriendSummary.kt | 2 +- .../com/alarmy/near/presentation/feature/home/HomeScreen.kt | 4 ++-- .../alarmy/near/presentation/feature/home/HomeViewModel.kt | 2 +- .../near/presentation/feature/home/component/ContactItem.kt | 4 ++-- .../near/presentation/feature/home/component/MyContacts.kt | 4 ++-- 9 files changed, 13 insertions(+), 13 deletions(-) rename Near/app/src/main/java/com/alarmy/near/model/{ => friendsummary}/ContactFrequencyLevel.kt (61%) rename Near/app/src/main/java/com/alarmy/near/model/{ => friendsummary}/FriendSummary.kt (86%) diff --git a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt index c6468ee0..07450b6a 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt @@ -1,7 +1,7 @@ package com.alarmy.near.data.mapper -import com.alarmy.near.model.ContactFrequencyLevel -import com.alarmy.near.model.FriendSummary +import com.alarmy.near.model.friendsummary.ContactFrequencyLevel +import com.alarmy.near.model.friendsummary.FriendSummary import com.alarmy.near.network.response.FriendSummaryEntity fun FriendSummaryEntity.toModel(): FriendSummary = 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 8449b415..9d5ff99f 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 @@ -4,7 +4,7 @@ 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 +import com.alarmy.near.model.friendsummary.FriendSummary import com.alarmy.near.model.monthly.MonthlyFriend import com.alarmy.near.network.service.FriendService import kotlinx.coroutines.flow.Flow 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 15c97570..81e6761b 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/FriendRepository.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/FriendRepository.kt @@ -2,7 +2,7 @@ package com.alarmy.near.data.repository import com.alarmy.near.model.Friend import com.alarmy.near.model.FriendRecord -import com.alarmy.near.model.FriendSummary +import com.alarmy.near.model.friendsummary.FriendSummary import com.alarmy.near.model.monthly.MonthlyFriend import kotlinx.coroutines.flow.Flow diff --git a/Near/app/src/main/java/com/alarmy/near/model/ContactFrequencyLevel.kt b/Near/app/src/main/java/com/alarmy/near/model/friendsummary/ContactFrequencyLevel.kt similarity index 61% rename from Near/app/src/main/java/com/alarmy/near/model/ContactFrequencyLevel.kt rename to Near/app/src/main/java/com/alarmy/near/model/friendsummary/ContactFrequencyLevel.kt index 3d5c0df0..2da7665a 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/ContactFrequencyLevel.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/friendsummary/ContactFrequencyLevel.kt @@ -1,4 +1,4 @@ -package com.alarmy.near.model +package com.alarmy.near.model.friendsummary enum class ContactFrequencyLevel { LOW, diff --git a/Near/app/src/main/java/com/alarmy/near/model/FriendSummary.kt b/Near/app/src/main/java/com/alarmy/near/model/friendsummary/FriendSummary.kt similarity index 86% rename from Near/app/src/main/java/com/alarmy/near/model/FriendSummary.kt rename to Near/app/src/main/java/com/alarmy/near/model/friendsummary/FriendSummary.kt index b0fddf88..7d5799e3 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/FriendSummary.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/friendsummary/FriendSummary.kt @@ -1,4 +1,4 @@ -package com.alarmy.near.model +package com.alarmy.near.model.friendsummary import androidx.compose.runtime.Immutable 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 0f16dcaf..970753ea 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 @@ -52,8 +52,8 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R -import com.alarmy.near.model.ContactFrequencyLevel -import com.alarmy.near.model.FriendSummary +import com.alarmy.near.model.friendsummary.ContactFrequencyLevel +import com.alarmy.near.model.friendsummary.FriendSummary import com.alarmy.near.model.monthly.MonthlyFriend import com.alarmy.near.model.monthly.MonthlyFriendType import com.alarmy.near.presentation.feature.home.component.MyContacts diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeViewModel.kt index 899c1503..c8a4ccfe 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeViewModel.kt @@ -3,7 +3,7 @@ package com.alarmy.near.presentation.feature.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.alarmy.near.data.repository.FriendRepository -import com.alarmy.near.model.FriendSummary +import com.alarmy.near.model.friendsummary.FriendSummary import com.alarmy.near.model.monthly.MonthlyFriend import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt index ed45d6a9..892176ff 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt @@ -19,8 +19,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.alarmy.near.R -import com.alarmy.near.model.ContactFrequencyLevel -import com.alarmy.near.model.FriendSummary +import com.alarmy.near.model.friendsummary.ContactFrequencyLevel +import com.alarmy.near.model.friendsummary.FriendSummary import com.alarmy.near.presentation.ui.extension.onNoRippleClick import com.alarmy.near.presentation.ui.theme.NearTheme diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt index 1376552e..3e1ac181 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt @@ -17,8 +17,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.alarmy.near.model.ContactFrequencyLevel -import com.alarmy.near.model.FriendSummary +import com.alarmy.near.model.friendsummary.ContactFrequencyLevel +import com.alarmy.near.model.friendsummary.FriendSummary import com.alarmy.near.presentation.ui.theme.NearTheme private const val OVERFLOW_WIDTH_OF_CONTACT_ITEM_BY_NAME_TEXT = 34 From 7492ab056a14e7ad6da7a58739cc49f21536dd84 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 24 Aug 2025 18:28:09 +0900 Subject: [PATCH 005/100] =?UTF-8?q?feat:=20friendProfileScreen=20=EC=83=81?= =?UTF-8?q?=EB=8B=A8=20=EC=83=81=ED=83=9C=EB=B0=94=20=ED=8C=A8=EB=94=A9=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../friendprofile/FriendProfileScreen.kt | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index 95e1a2d5..7df03b63 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -12,11 +12,13 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentSize @@ -37,6 +39,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -45,6 +48,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import com.alarmy.near.R import com.alarmy.near.presentation.feature.friendprofile.component.CallButton import com.alarmy.near.presentation.feature.friendprofile.component.MessageButton @@ -55,6 +59,7 @@ import com.alarmy.near.presentation.ui.theme.NearTheme @Composable fun FriendProfileRoute( + viewModel: FriendProfileViewModel = hiltViewModel(), onShowErrorSnackBar: (throwable: Throwable?) -> Unit, onClickBackButton: () -> Unit = {}, ) { @@ -68,9 +73,15 @@ fun FriendProfileScreen( modifier: Modifier = Modifier, onClickBackButton: () -> Unit = {}, ) { - // TODO Home 머지시 상단 패딩 + status 색상 변경 + val density = LocalDensity.current + val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } val currentTabPosition = remember { mutableIntStateOf(0) } - Box(modifier = modifier.padding(bottom = 24.dp)) { + Box( + modifier = + modifier + .background(NearTheme.colors.WHITE_FFFFFF) + .padding(top = statusBarHeightDp, bottom = 24.dp), + ) { Column( modifier = Modifier @@ -83,7 +94,10 @@ fun FriendProfileScreen( onClickBackButton = onClickBackButton, menuButton = { Image( - modifier = Modifier.onNoRippleClick(onClick = {}).padding(end = 20.dp), + modifier = + Modifier + .onNoRippleClick(onClick = {}) + .padding(end = 20.dp), painter = painterResource(R.drawable.ic_32_menu), contentDescription = stringResource(R.string.common_menu_button_description), ) From d2cc816e3c68351bc416fbfef0560451b17f34bb Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 24 Aug 2025 18:43:09 +0900 Subject: [PATCH 006/100] =?UTF-8?q?chore:=20string=20res=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Near/app/src/main/res/values/strings.xml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml index 3606877d..3f092a7f 100644 --- a/Near/app/src/main/res/values/strings.xml +++ b/Near/app/src/main/res/values/strings.xml @@ -16,6 +16,7 @@ 연락처 추가 가까워 지고 싶은 사람을\n추가해보세요. 사람 추가 + 사람 추가 전화걸기 @@ -32,10 +33,12 @@ 친구 가족 지인 + + 매일 매주 2주 매달 6개월 - 사람 추가 + 프로필 상세 From 4979f358f5e6344722f395226991ee91b6c965a4 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 24 Aug 2025 18:43:21 +0900 Subject: [PATCH 007/100] =?UTF-8?q?feat:=20dropdown=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../friendprofile/FriendProfileScreen.kt | 58 ++++++++++++++++--- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index 7df03b63..a7239d58 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -25,6 +25,8 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Surface import androidx.compose.material3.Tab @@ -35,6 +37,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -76,6 +79,7 @@ fun FriendProfileScreen( val density = LocalDensity.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } val currentTabPosition = remember { mutableIntStateOf(0) } + val dropdownState = remember { mutableStateOf(false) } Box( modifier = modifier @@ -90,19 +94,55 @@ fun FriendProfileScreen( .background(NearTheme.colors.WHITE_FFFFFF), ) { NearTopAppbar( - title = "프로필 상세", + title = stringResource(R.string.friend_profile_title), onClickBackButton = onClickBackButton, menuButton = { - Image( - modifier = - Modifier - .onNoRippleClick(onClick = {}) - .padding(end = 20.dp), - painter = painterResource(R.drawable.ic_32_menu), - contentDescription = stringResource(R.string.common_menu_button_description), - ) + Column(modifier = Modifier.padding(end = 20.dp)) { + Image( + modifier = + Modifier + .onNoRippleClick(onClick = { + dropdownState.value = true + }), + painter = painterResource(R.drawable.ic_32_menu), + contentDescription = stringResource(R.string.common_menu_button_description), + ) + DropdownMenu( + modifier = Modifier.background(color = NearTheme.colors.WHITE_FFFFFF), + expanded = dropdownState.value, + shape = RoundedCornerShape(12.dp), + onDismissRequest = { dropdownState.value = false }, + ) { + DropdownMenuItem( + onClick = { +// onUpdateFriend() + dropdownState.value = false + }, + text = { + Text( + "수정", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + }, + ) + DropdownMenuItem( + onClick = { + dropdownState.value = false + }, + text = { + Text( + "삭제", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + }, + ) + } + } }, ) + Spacer(modifier = Modifier.height(18.dp)) Row( modifier = From 6d55bbb324ace2cfa9c6199774b21a81c3a28244 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Mon, 25 Aug 2025 00:55:06 +0900 Subject: [PATCH 008/100] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alarmy/near/data/mapper/FriendMapper.kt | 4 +- .../repository/DefaultFriendRepository.kt | 6 +- .../main/java/com/alarmy/near/model/Friend.kt | 2 +- .../near/network/response/FriendEntity.kt | 6 +- .../friendprofile/FriendProfileScreen.kt | 415 ++++++++++-------- .../friendprofile/FriendProfileViewModel.kt | 53 +++ .../navigation/FriendProfileNavigation.kt | 3 + .../friendprofile/uistate/FriendState.kt | 15 + 8 files changed, 314 insertions(+), 190 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendState.kt 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 0aab0634..3d3cac86 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 @@ -16,9 +16,9 @@ fun FriendEntity.toModel(): Friend = imageUrl = imageUrl, relation = relation, name = name, - contactFrequency = contactFrequencyEntity.toModel(), + contactFrequency = contactFrequency.toModel(), birthday = birthday, - anniversaryList = anniversaryEntityList.map { it.toModel() }, + anniversaryList = anniversaryList.map { it.toModel() }, memo = memo, phone = phone, lastContactAt = lastContactAt, 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 9d5ff99f..cbb7bfd9 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,5 +1,6 @@ package com.alarmy.near.data.repository +import android.util.Log import com.alarmy.near.data.mapper.toModel import com.alarmy.near.data.mapper.toRequest import com.alarmy.near.model.Friend @@ -27,6 +28,7 @@ class DefaultFriendRepository override fun fetchMonthlyFriends(): Flow> = flow { + Log.d("test", "repository") emit( friendService.fetchMonthlyFriends().map { it.toModel() @@ -61,6 +63,6 @@ class DefaultFriendRepository override fun recordContact(friendId: String): Flow = flow { val response = friendService.recordContact(friendId) - emit(response.message) // CommonMessageEntity.message 라고 가정 - } + emit(response.message) // CommonMessageEntity.message 라고 가정 + } } diff --git a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt index 1660f7ad..f23b6ad4 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt @@ -2,7 +2,7 @@ package com.alarmy.near.model data class Friend( val friendId: String, - val imageUrl: String, + val imageUrl: String?, val relation: String, val name: String, val contactFrequency: ContactFrequency, diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt b/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt index 3862ebd7..16e38353 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt @@ -5,12 +5,12 @@ import kotlinx.serialization.Serializable @Serializable data class FriendEntity( val friendId: String, - val imageUrl: String, + val imageUrl: String?, val relation: String, val name: String, - val contactFrequencyEntity: ContactFrequencyEntity, + val contactFrequency: ContactFrequencyEntity, val birthday: String?, - val anniversaryEntityList: List, + val anniversaryList: List, val memo: String?, val phone: String?, val lastContactAt: String?, diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index a7239d58..73ed4b9d 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider @@ -52,9 +53,13 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R +import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.Friend import com.alarmy.near.presentation.feature.friendprofile.component.CallButton import com.alarmy.near.presentation.feature.friendprofile.component.MessageButton +import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendState import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar import com.alarmy.near.presentation.ui.component.button.NearSolidTypeButton import com.alarmy.near.presentation.ui.extension.onNoRippleClick @@ -65,16 +70,22 @@ fun FriendProfileRoute( viewModel: FriendProfileViewModel = hiltViewModel(), onShowErrorSnackBar: (throwable: Throwable?) -> Unit, onClickBackButton: () -> Unit = {}, + onEditFriendInfo: (Friend) -> Unit = {}, ) { + val friendState = viewModel.friendFlow.collectAsStateWithLifecycle() FriendProfileScreen( + friendState = friendState.value, onClickBackButton = onClickBackButton, + onEditFriendInfo = onEditFriendInfo, ) } @Composable fun FriendProfileScreen( modifier: Modifier = Modifier, + friendState: FriendState, onClickBackButton: () -> Unit = {}, + onEditFriendInfo: (Friend) -> Unit = {}, ) { val density = LocalDensity.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } @@ -86,207 +97,227 @@ fun FriendProfileScreen( .background(NearTheme.colors.WHITE_FFFFFF) .padding(top = statusBarHeightDp, bottom = 24.dp), ) { - Column( - modifier = - Modifier - .align(Alignment.TopStart) - .fillMaxSize() - .background(NearTheme.colors.WHITE_FFFFFF), - ) { - NearTopAppbar( - title = stringResource(R.string.friend_profile_title), - onClickBackButton = onClickBackButton, - menuButton = { - Column(modifier = Modifier.padding(end = 20.dp)) { - Image( + when (friendState) { + is FriendState.Success -> { + val friend = friendState.friend + Column( + modifier = + Modifier + .align(Alignment.TopStart) + .fillMaxSize() + .background(NearTheme.colors.WHITE_FFFFFF), + ) { + NearTopAppbar( + title = stringResource(R.string.friend_profile_title), + onClickBackButton = onClickBackButton, + menuButton = { + Column(modifier = Modifier.padding(end = 20.dp)) { + Image( + modifier = + Modifier + .onNoRippleClick(onClick = { + dropdownState.value = true + }), + painter = painterResource(R.drawable.ic_32_menu), + contentDescription = stringResource(R.string.common_menu_button_description), + ) + DropdownMenu( + modifier = Modifier.background(color = NearTheme.colors.WHITE_FFFFFF), + expanded = dropdownState.value, + shape = RoundedCornerShape(12.dp), + onDismissRequest = { dropdownState.value = false }, + ) { + DropdownMenuItem( + onClick = { + onEditFriendInfo(friend) + dropdownState.value = false + }, + text = { + Text( + "수정", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + }, + ) + DropdownMenuItem( + onClick = { + dropdownState.value = false + }, + text = { + Text( + "삭제", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + }, + ) + } + } + }, + ) + + Spacer(modifier = Modifier.height(18.dp)) + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( modifier = - Modifier - .onNoRippleClick(onClick = { - dropdownState.value = true - }), - painter = painterResource(R.drawable.ic_32_menu), - contentDescription = stringResource(R.string.common_menu_button_description), - ) - DropdownMenu( - modifier = Modifier.background(color = NearTheme.colors.WHITE_FFFFFF), - expanded = dropdownState.value, - shape = RoundedCornerShape(12.dp), - onDismissRequest = { dropdownState.value = false }, + Modifier, ) { - DropdownMenuItem( - onClick = { -// onUpdateFriend() - dropdownState.value = false - }, - text = { - Text( - "수정", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - }, + Image( + modifier = Modifier.align(Alignment.Center), + painter = painterResource(R.drawable.img_80_user1), + contentDescription = null, ) - DropdownMenuItem( - onClick = { - dropdownState.value = false - }, - text = { - Text( - "삭제", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - }, + Image( + modifier = + Modifier + .align(Alignment.TopEnd) + .offset(x = 2.dp, y = (-2).dp), + painter = painterResource(R.drawable.ic_visual_24_emoji_100), + contentDescription = null, + ) + } + Spacer(modifier = Modifier.width(24.dp)) + Column { + Text( + modifier = Modifier.widthIn(max = 145.dp), + text = friend.name, + style = NearTheme.typography.B1_16_BOLD, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "3월 22일 더 가까워졌어요", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLUE01_5AA2E9, ) } } - }, - ) - - Spacer(modifier = Modifier.height(18.dp)) - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = - Modifier, - ) { - Image( - modifier = Modifier.align(Alignment.Center), - painter = painterResource(R.drawable.img_80_user1), - contentDescription = null, - ) - Image( + Spacer(modifier = Modifier.height(24.dp)) + Row( modifier = Modifier - .align(Alignment.TopEnd) - .offset(x = 2.dp, y = (-2).dp), - painter = painterResource(R.drawable.ic_visual_24_emoji_100), - contentDescription = null, - ) - } - Spacer(modifier = Modifier.width(24.dp)) - Column { - Text( - modifier = Modifier.widthIn(max = 145.dp), - text = "일이삼사오육칠", - style = NearTheme.typography.B1_16_BOLD, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "3월 22일 더 가까워졌어요", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLUE01_5AA2E9, - ) - } - } - Spacer(modifier = Modifier.height(24.dp)) - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - ) { - CallButton(modifier = Modifier.weight(1f), onClick = {}) - Spacer(modifier = Modifier.width(7.dp)) - MessageButton(Modifier.weight(1f), onClick = {}) - } - Spacer(modifier = Modifier.height(24.dp)) - TabRow( - modifier = - Modifier - .padding(horizontal = 25.dp) - .width(170.dp), - containerColor = NearTheme.colors.WHITE_FFFFFF, - selectedTabIndex = 0, - divider = {}, - indicator = { - TabRowDefaults.SecondaryIndicator( + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) { + CallButton(modifier = Modifier.weight(1f), onClick = {}) + Spacer(modifier = Modifier.width(7.dp)) + MessageButton(Modifier.weight(1f), onClick = {}) + } + Spacer(modifier = Modifier.height(24.dp)) + TabRow( modifier = Modifier - .customTabIndicatorOffset( - it[currentTabPosition.intValue], - 80.dp, - ), // 넓이, 애니메이션 지정 - // 모양 지정 - height = 3.dp, - color = NearTheme.colors.BLUE01_5AA2E9, - ) - }, - ) { - Tab( - modifier = - Modifier - .width(85.dp) - .height(50.dp), - selected = true, - onClick = { - currentTabPosition.intValue = 0 - }, - ) { + .padding(horizontal = 25.dp) + .width(170.dp), + containerColor = NearTheme.colors.WHITE_FFFFFF, + selectedTabIndex = 0, + divider = {}, + indicator = { + TabRowDefaults.SecondaryIndicator( + modifier = + Modifier + .customTabIndicatorOffset( + it[currentTabPosition.intValue], + 80.dp, + ), // 넓이, 애니메이션 지정 + // 모양 지정 + height = 3.dp, + color = NearTheme.colors.BLUE01_5AA2E9, + ) + }, + ) { + Tab( + modifier = + Modifier + .width(85.dp) + .height(50.dp), + selected = true, + onClick = { + currentTabPosition.intValue = 0 + }, + ) { + if (currentTabPosition.intValue == 0) { + Text( + text = stringResource(R.string.friend_profile_tab_text_profile), + style = NearTheme.typography.B2_14_BOLD, + color = NearTheme.colors.BLACK_1A1A1A, + ) + } else { + Text( + text = stringResource(R.string.friend_profile_tab_text_profile), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY02_B7B7B7, + ) + } + } + Tab( + modifier = + Modifier + .width(85.dp) + .height(50.dp), + selected = true, + onClick = { + currentTabPosition.intValue = 1 + }, + ) { + if (currentTabPosition.intValue == 1) { + Text( + text = stringResource(R.string.friend_profile_tab_text_record), + style = NearTheme.typography.B2_14_BOLD, + color = NearTheme.colors.BLACK_1A1A1A, + ) + } else { + Text( + text = stringResource(R.string.friend_profile_tab_text_record), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY02_B7B7B7, + ) + } + } + } + HorizontalDivider(thickness = 1.dp, color = NearTheme.colors.GRAY03_EBEBEB) if (currentTabPosition.intValue == 0) { - Text( - text = stringResource(R.string.friend_profile_tab_text_profile), - style = NearTheme.typography.B2_14_BOLD, - color = NearTheme.colors.BLACK_1A1A1A, - ) + ProfileTab() } else { - Text( - text = stringResource(R.string.friend_profile_tab_text_profile), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY02_B7B7B7, - ) + RecordTab() } } - Tab( + NearSolidTypeButton( modifier = Modifier - .width(85.dp) - .height(50.dp), - selected = true, - onClick = { - currentTabPosition.intValue = 1 - }, - ) { - if (currentTabPosition.intValue == 1) { - Text( - text = stringResource(R.string.friend_profile_tab_text_record), - style = NearTheme.typography.B2_14_BOLD, - color = NearTheme.colors.BLACK_1A1A1A, - ) - } else { - Text( - text = stringResource(R.string.friend_profile_tab_text_record), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY02_B7B7B7, - ) - } + .fillMaxWidth() + .padding(horizontal = 20.dp) + .align(Alignment.BottomCenter), + contentPadding = PaddingValues(vertical = 17.dp), + enabled = true, + onClick = {}, + text = stringResource(R.string.friend_profile_record_button_text), + ) + } + + is FriendState.Loading -> { + Box(modifier = Modifier.fillMaxSize()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } - HorizontalDivider(thickness = 1.dp, color = NearTheme.colors.GRAY03_EBEBEB) - if (currentTabPosition.intValue == 0) { - ProfileTab() - } else { - RecordTab() + + is FriendState.Error -> { + Box(modifier = Modifier.fillMaxSize()) { + Text( + modifier = Modifier.align(Alignment.Center), + text = "프로필 정보를 불러오는데 실패했습니다.", + ) + } } } - NearSolidTypeButton( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - .align(Alignment.BottomCenter), - contentPadding = PaddingValues(vertical = 17.dp), - enabled = true, - onClick = {}, - text = stringResource(R.string.friend_profile_record_button_text), - ) } } @@ -513,6 +544,26 @@ fun Modifier.customTabIndicatorOffset( @Composable fun FriendProfileScreenPreview() { NearTheme { - FriendProfileScreen() + FriendProfileScreen( + friendState = + FriendState.Success( + Friend( + friendId = "adfaggasf", + imageUrl = "", + relation = "FRIEND", + name = "", + contactFrequency = + ContactFrequency( + contactWeek = "EVERY_DAY", + dayOfWeek = "MONDAY", + ), + birthday = "1998-11-13", + anniversaryList = listOf(), + memo = "", + phone = "", + lastContactAt = "", + ), + ), + ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt new file mode 100644 index 00000000..07fc49e6 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt @@ -0,0 +1,53 @@ +package com.alarmy.near.presentation.feature.friendprofile + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.alarmy.near.data.repository.FriendRepository +import com.alarmy.near.presentation.feature.friendprofile.navigation.RouteFriendProfile +import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import javax.inject.Inject + +@HiltViewModel +class FriendProfileViewModel + @Inject + constructor( + savedStateHandle: SavedStateHandle, + private val friendRepository: FriendRepository, + ) : ViewModel() { + private val friendId: String = savedStateHandle.toRoute().friendId + private val _errorEvent = Channel() + val errorEvent = _errorEvent.receiveAsFlow() + private val _friendFlow: MutableStateFlow = MutableStateFlow(FriendState.Loading) + val friendFlow: StateFlow = _friendFlow.asStateFlow() + + init { + fetchFriend() + } + + fun fetchFriend() { + friendRepository + .fetchFriendById(friendId) + .onEach { friend -> + _friendFlow.value = FriendState.Success(friend) + }.catch { error -> + Log.d("test1", error.message.toString()) + _friendFlow.value = FriendState.Error("데이터를 가져오는데 실패했습니다.") + _errorEvent.send(error) // UI에서 단발성 이벤트로도 쓸 수 있음 + }.launchIn(viewModelScope) + } + + fun deleteFriend() { + } + } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt index c3b4608a..7dd00f59 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt @@ -4,6 +4,7 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable +import com.alarmy.near.model.Friend import com.alarmy.near.presentation.feature.friendprofile.FriendProfileRoute import kotlinx.serialization.Serializable @@ -22,11 +23,13 @@ fun NavController.navigateToFriendProfile( fun NavGraphBuilder.friendProfileNavGraph( onShowErrorSnackBar: (throwable: Throwable?) -> Unit, onClickBackButton: () -> Unit, + onEditFriendInfo: (Friend) -> Unit = {}, ) { composable { backStackEntry -> FriendProfileRoute( onShowErrorSnackBar = onShowErrorSnackBar, onClickBackButton = onClickBackButton, + onEditFriendInfo = onEditFriendInfo, ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendState.kt new file mode 100644 index 00000000..b9b089a7 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendState.kt @@ -0,0 +1,15 @@ +package com.alarmy.near.presentation.feature.friendprofile.uistate + +import com.alarmy.near.model.Friend + +sealed interface FriendState { + object Loading : FriendState + + data class Success( + val friend: Friend, + ) : FriendState + + data class Error( + val errorMessage: String, + ) : FriendState +} From 3dfd47db176e962efd6786c41d969a0153fd247f Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Mon, 25 Aug 2025 01:16:05 +0900 Subject: [PATCH 009/100] =?UTF-8?q?feat:=20=EC=A0=84=ED=99=94=20=EB=B0=8F?= =?UTF-8?q?=20=EB=AC=B8=EC=9E=90=20=ED=99=94=EB=A9=B4=20=EC=9D=B4=EB=8F=99?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../friendprofile/FriendProfileScreen.kt | 26 +++++++++++++++++-- .../navigation/FriendProfileNavigation.kt | 4 +++ .../presentation/feature/main/NearNavHost.kt | 17 ++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index 73ed4b9d..fb957f58 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -71,12 +71,16 @@ fun FriendProfileRoute( onShowErrorSnackBar: (throwable: Throwable?) -> Unit, onClickBackButton: () -> Unit = {}, onEditFriendInfo: (Friend) -> Unit = {}, + onClickCallButton: (phoneNumber: String) -> Unit = {}, + onClickMessageButton: (phoneNumber: String) -> Unit = {}, ) { val friendState = viewModel.friendFlow.collectAsStateWithLifecycle() FriendProfileScreen( friendState = friendState.value, onClickBackButton = onClickBackButton, onEditFriendInfo = onEditFriendInfo, + onClickCallButton = onClickCallButton, + onClickMessageButton = onClickMessageButton, ) } @@ -86,6 +90,8 @@ fun FriendProfileScreen( friendState: FriendState, onClickBackButton: () -> Unit = {}, onEditFriendInfo: (Friend) -> Unit = {}, + onClickCallButton: (phoneNumber: String) -> Unit = {}, + onClickMessageButton: (phoneNumber: String) -> Unit = {}, ) { val density = LocalDensity.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } @@ -207,9 +213,25 @@ fun FriendProfileScreen( .fillMaxWidth() .padding(horizontal = 20.dp), ) { - CallButton(modifier = Modifier.weight(1f), onClick = {}) + CallButton( + modifier = Modifier.weight(1f), + enabled = !friend.phone.isNullOrBlank(), + onClick = { + friend.phone?.let { + onClickCallButton(friend.phone) + } + }, + ) Spacer(modifier = Modifier.width(7.dp)) - MessageButton(Modifier.weight(1f), onClick = {}) + MessageButton( + Modifier.weight(1f), + enabled = !friend.phone.isNullOrBlank(), + onClick = { + friend.phone?.let { + onClickMessageButton(friend.phone) + } + }, + ) } Spacer(modifier = Modifier.height(24.dp)) TabRow( diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt index 7dd00f59..c28df80c 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt @@ -24,12 +24,16 @@ fun NavGraphBuilder.friendProfileNavGraph( onShowErrorSnackBar: (throwable: Throwable?) -> Unit, onClickBackButton: () -> Unit, onEditFriendInfo: (Friend) -> Unit = {}, + onClickCallButton: (phoneNumber: String) -> Unit = {}, + onClickMessageButton: (phoneNumber: String) -> Unit = {}, ) { composable { backStackEntry -> FriendProfileRoute( onShowErrorSnackBar = onShowErrorSnackBar, onClickBackButton = onClickBackButton, onEditFriendInfo = onEditFriendInfo, + onClickCallButton = onClickCallButton, + onClickMessageButton = onClickMessageButton, ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt index 94f9e995..39b75bf0 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt @@ -1,7 +1,11 @@ package com.alarmy.near.presentation.feature.main +import android.content.Intent +import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.core.net.toUri import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import com.alarmy.near.presentation.feature.friendprofile.navigation.friendProfileNavGraph @@ -16,6 +20,7 @@ internal fun NearNavHost( navController: NavHostController, onShowSnackbar: (Throwable?) -> Unit = { _ -> }, ) { + val context = LocalContext.current /* * 화면 이동 및 구성을 위한 컴포저블 함수입니다. * */ @@ -26,6 +31,18 @@ internal fun NearNavHost( ) { friendProfileNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = { navController.popBackStack() + }, onClickCallButton = { phoneNumber -> + val intent = + Intent(Intent.ACTION_DIAL).apply { + data = "tel:$phoneNumber".toUri() + } + context.startActivity(intent) + }, onClickMessageButton = { phoneNumber -> + val intent = + Intent(Intent.ACTION_VIEW).apply { + data = "sms:$phoneNumber".toUri() + } + context.startActivity(intent) }) friendProfileEditorNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = { navController.popBackStack() From e69e0434072f9072f76cff43499a7d9af9b504c5 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Mon, 25 Aug 2025 23:25:22 +0900 Subject: [PATCH 010/100] =?UTF-8?q?docs:=20res=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8A=94=20model=20TODO=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI Layer로 이동 --- Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt | 1 + .../main/java/com/alarmy/near/model/monthly/MonthlyFriendType.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt b/Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt index 0d39369d..e11e6d47 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt @@ -3,6 +3,7 @@ package com.alarmy.near.model import androidx.annotation.StringRes import com.alarmy.near.R +// TODO StringRes UI-Layer 이동 enum class ReminderInterval( @param:StringRes val labelRes: Int, ) { diff --git a/Near/app/src/main/java/com/alarmy/near/model/monthly/MonthlyFriendType.kt b/Near/app/src/main/java/com/alarmy/near/model/monthly/MonthlyFriendType.kt index 553d6939..0efc9fb0 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/monthly/MonthlyFriendType.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/monthly/MonthlyFriendType.kt @@ -3,6 +3,7 @@ package com.alarmy.near.model.monthly import androidx.annotation.DrawableRes import com.alarmy.near.R +// TODO Drawable Res UI-Layer 이동 enum class MonthlyFriendType( @param:DrawableRes val imageSrc: Int, ) { From 7502dae6cbd97d0b0984604663d095ff5e3c5291 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Mon, 25 Aug 2025 23:31:16 +0900 Subject: [PATCH 011/100] =?UTF-8?q?refactor:=20remindInterval=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=99=80=20=EB=84=A4=EC=9D=B4=EB=B0=8D=EC=9D=84=20?= =?UTF-8?q?=EB=98=91=EA=B0=99=EC=9D=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/alarmy/near/data/mapper/FriendMapper.kt | 5 +++-- .../app/src/main/java/com/alarmy/near/model/Friend.kt | 2 +- .../java/com/alarmy/near/model/ReminderInterval.kt | 11 +++++------ .../feature/friendprofile/FriendProfileScreen.kt | 2 +- .../component/ReminderIntervalBottomSheet.kt | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) 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 3d3cac86..b76e7245 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 @@ -3,6 +3,7 @@ package com.alarmy.near.data.mapper import com.alarmy.near.model.Anniversary import com.alarmy.near.model.ContactFrequency import com.alarmy.near.model.Friend +import com.alarmy.near.model.ReminderInterval import com.alarmy.near.network.request.AnniversaryRequest import com.alarmy.near.network.request.ContactFrequencyRequest import com.alarmy.near.network.request.FriendRequest @@ -26,7 +27,7 @@ fun FriendEntity.toModel(): Friend = fun ContactFrequencyEntity.toModel(): ContactFrequency = ContactFrequency( - contactWeek = contactWeek, + reminderInterval = ReminderInterval.valueOf(contactWeek), dayOfWeek = dayOfWeek, ) @@ -50,7 +51,7 @@ fun Friend.toRequest(): FriendRequest = fun ContactFrequency.toRequest(): ContactFrequencyRequest = ContactFrequencyRequest( - contactWeek = contactWeek, + contactWeek = reminderInterval.toString(), dayOfWeek = dayOfWeek, ) diff --git a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt index f23b6ad4..ec372bc7 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt @@ -14,7 +14,7 @@ data class Friend( ) data class ContactFrequency( - val contactWeek: String, + val reminderInterval: ReminderInterval, val dayOfWeek: String, ) diff --git a/Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt b/Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt index e11e6d47..5bbd80f8 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt @@ -3,13 +3,12 @@ package com.alarmy.near.model import androidx.annotation.StringRes import com.alarmy.near.R -// TODO StringRes UI-Layer 이동 enum class ReminderInterval( @param:StringRes val labelRes: Int, ) { - DAILY(R.string.reminder_interval_daily), // 매일 - WEEKLY(R.string.reminder_interval_weekly), // 매주 - BIWEEKLY(R.string.reminder_interval_biweekly), // 2주 - MONTHLY(R.string.reminder_interval_monthly), // 매달 - SEMIANNUAL(R.string.reminder_interval_semiannual), // 6개월 + EVERY_DAY(R.string.reminder_interval_daily), + EVERY_WEEK(R.string.reminder_interval_weekly), + EVERY_TWO_WEEK(R.string.reminder_interval_biweekly), + EVERY_MONTH(R.string.reminder_interval_monthly), + EVERY_SIX_MONTH(R.string.reminder_interval_semiannual), } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index fb957f58..4c100d82 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -576,7 +576,7 @@ fun FriendProfileScreenPreview() { name = "", contactFrequency = ContactFrequency( - contactWeek = "EVERY_DAY", + reminderInterval = "EVERY_DAY", dayOfWeek = "MONDAY", ), birthday = "1998-11-13", diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/ReminderIntervalBottomSheet.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/ReminderIntervalBottomSheet.kt index b4f43b77..ce7902f1 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/ReminderIntervalBottomSheet.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/ReminderIntervalBottomSheet.kt @@ -44,7 +44,7 @@ import com.alarmy.near.presentation.ui.theme.NearTheme @Composable fun ReminderIntervalBottomSheet( modifier: Modifier = Modifier, - selectedReminderInterval: ReminderInterval = ReminderInterval.WEEKLY, + selectedReminderInterval: ReminderInterval = ReminderInterval.EVERY_WEEK, onSelectReminderInterval: (ReminderInterval) -> Unit = {}, sheetState: SheetState = rememberModalBottomSheetState( From f76e95c2c47c2d1c8906545bee4a9489f60926d6 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Mon, 25 Aug 2025 23:33:04 +0900 Subject: [PATCH 012/100] =?UTF-8?q?refactor:=20relation=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EC=9D=84=20enum=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/alarmy/near/data/mapper/FriendMapper.kt | 5 +++-- Near/app/src/main/java/com/alarmy/near/model/Friend.kt | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) 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 b76e7245..3e19c710 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 @@ -3,6 +3,7 @@ package com.alarmy.near.data.mapper import com.alarmy.near.model.Anniversary import com.alarmy.near.model.ContactFrequency import com.alarmy.near.model.Friend +import com.alarmy.near.model.Relation import com.alarmy.near.model.ReminderInterval import com.alarmy.near.network.request.AnniversaryRequest import com.alarmy.near.network.request.ContactFrequencyRequest @@ -15,7 +16,7 @@ fun FriendEntity.toModel(): Friend = Friend( friendId = friendId, imageUrl = imageUrl, - relation = relation, + relation = Relation.valueOf(relation), name = name, contactFrequency = contactFrequency.toModel(), birthday = birthday, @@ -41,7 +42,7 @@ fun AnniversaryEntity.toModel(): Anniversary = fun Friend.toRequest(): FriendRequest = FriendRequest( name = name, - relation = relation, + relation = relation.toString(), contactFrequency = contactFrequency.toRequest(), birthday = birthday, anniversaryList = anniversaryList.map { it.toRequest() }, diff --git a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt index ec372bc7..f4221203 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt @@ -3,7 +3,7 @@ package com.alarmy.near.model data class Friend( val friendId: String, val imageUrl: String?, - val relation: String, + val relation: Relation, val name: String, val contactFrequency: ContactFrequency, val birthday: String?, From 19df3f3a95c411ad55e564d4f52507aa4d502d98 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Tue, 26 Aug 2025 20:03:07 +0900 Subject: [PATCH 013/100] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/alarmy/near/model/Relation.kt | 13 +++-- .../friendprofile/FriendProfileScreen.kt | 56 +++++++++++++------ Near/app/src/main/res/values/strings.xml | 4 ++ 3 files changed, 53 insertions(+), 20 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/model/Relation.kt b/Near/app/src/main/java/com/alarmy/near/model/Relation.kt index e01a7a52..1c84a7ba 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/Relation.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/Relation.kt @@ -1,7 +1,12 @@ package com.alarmy.near.model -enum class Relation { - FRIEND, - FAMILY, - ACQUAINTANCE, +import androidx.annotation.StringRes +import com.alarmy.near.R + +enum class Relation( + @param:StringRes val resId: Int, +) { + FRIEND(R.string.relation_friend), + FAMILY(R.string.relation_family), + ACQUAINTANCE(R.string.relation_acquaintance), } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index 4c100d82..7ab26c44 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -57,6 +57,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R import com.alarmy.near.model.ContactFrequency import com.alarmy.near.model.Friend +import com.alarmy.near.model.Relation +import com.alarmy.near.model.ReminderInterval import com.alarmy.near.presentation.feature.friendprofile.component.CallButton import com.alarmy.near.presentation.feature.friendprofile.component.MessageButton import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendState @@ -64,6 +66,8 @@ import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar import com.alarmy.near.presentation.ui.component.button.NearSolidTypeButton import com.alarmy.near.presentation.ui.extension.onNoRippleClick import com.alarmy.near.presentation.ui.theme.NearTheme +import java.time.LocalDate +import java.time.format.DateTimeFormatter @Composable fun FriendProfileRoute( @@ -185,7 +189,7 @@ fun FriendProfileScreen( Modifier .align(Alignment.TopEnd) .offset(x = 2.dp, y = (-2).dp), - painter = painterResource(R.drawable.ic_visual_24_emoji_100), + painter = painterResource(R.drawable.ic_visual_24_emoji_0), contentDescription = null, ) } @@ -198,12 +202,18 @@ fun FriendProfileScreen( maxLines = 2, overflow = TextOverflow.Ellipsis, ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "3월 22일 더 가까워졌어요", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLUE01_5AA2E9, - ) + if (friend.lastContactAt != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = + stringResource( + R.string.friend_profile_last_contact_date_format, + friend.lastContactAt.lastContactFormat(), + ), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLUE01_5AA2E9, + ) + } } } Spacer(modifier = Modifier.height(24.dp)) @@ -307,7 +317,7 @@ fun FriendProfileScreen( } HorizontalDivider(thickness = 1.dp, color = NearTheme.colors.GRAY03_EBEBEB) if (currentTabPosition.intValue == 0) { - ProfileTab() + ProfileTab(friend = friend) } else { RecordTab() } @@ -344,31 +354,37 @@ fun FriendProfileScreen( } @Composable -private fun ProfileTab(modifier: Modifier = Modifier) { +private fun ProfileTab( + modifier: Modifier = Modifier, + friend: Friend, +) { Column(modifier = modifier) { Spacer(modifier = Modifier.height(32.dp)) ProfileDetailInfo( category = stringResource(R.string.friend_profile_info_category_relation), - content = "친구", + content = stringResource(friend.relation.resId), ) Spacer(modifier = Modifier.height(16.dp)) ProfileDetailInfo( category = stringResource(R.string.friend_profile_info_category_term_of_contact), - content = "2주", + content = stringResource(friend.contactFrequency.reminderInterval.labelRes), ) Spacer(modifier = Modifier.height(16.dp)) ProfileDetailInfo( category = stringResource(R.string.friend_profile_info_category_birthday), - content = "1996.03.21", + content = friend.birthday?.replace("-", ".") ?: "-", ) Spacer(modifier = Modifier.height(16.dp)) ProfileDetailInfo( category = stringResource(R.string.friend_profile_info_category_anniversary), - content = "결혼기념일 (2020.06.24)", + content = + friend.anniversaryList.joinToString(" ") { + "${it.title} (${it.date})" + }, ) Spacer(modifier = Modifier.height(16.dp)) ProfileMemoInfo( - content = null, + content = friend.memo, ) } } @@ -562,6 +578,14 @@ fun Modifier.customTabIndicatorOffset( .width(currentTabWidth) } +private fun String.lastContactFormat(): String { + val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + val outputFormatter = DateTimeFormatter.ofPattern("M월 d일") + + val date = LocalDate.parse(this, inputFormatter) + return date.format(outputFormatter) +} + @Preview(showBackground = true) @Composable fun FriendProfileScreenPreview() { @@ -572,11 +596,11 @@ fun FriendProfileScreenPreview() { Friend( friendId = "adfaggasf", imageUrl = "", - relation = "FRIEND", + relation = Relation.FRIEND, name = "", contactFrequency = ContactFrequency( - reminderInterval = "EVERY_DAY", + reminderInterval = ReminderInterval.EVERY_TWO_WEEK, dayOfWeek = "MONDAY", ), birthday = "1998-11-13", diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml index 3f092a7f..e40a3007 100644 --- a/Near/app/src/main/res/values/strings.xml +++ b/Near/app/src/main/res/values/strings.xml @@ -41,4 +41,8 @@ 매달 6개월 프로필 상세 + 친구 + 가족 + 지인 + %1$s 더 가까워졌어요 From 3d793b5ea59e3f2731a74c89be6be6a252fad798 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Wed, 27 Aug 2025 00:33:07 +0900 Subject: [PATCH 014/100] =?UTF-8?q?feat:=20=EC=B1=99=EA=B9=80=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../near/data/mapper/FriendRecordMapper.kt | 12 +++- .../repository/DefaultFriendRepository.kt | 2 - .../friendprofile/FriendProfileScreen.kt | 62 ++++++++++++++---- .../friendprofile/FriendProfileViewModel.kt | 64 +++++++++++++++++-- .../uistate/FriendProfileUIEvent.kt | 9 +++ .../uistate/FriendShipRecordState.kt | 8 +++ 6 files changed, 136 insertions(+), 21 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendProfileUIEvent.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendShipRecordState.kt diff --git a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendRecordMapper.kt b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendRecordMapper.kt index 343e65b9..ce99b806 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendRecordMapper.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendRecordMapper.kt @@ -2,9 +2,19 @@ package com.alarmy.near.data.mapper import com.alarmy.near.model.FriendRecord import com.alarmy.near.network.response.FriendRecordEntity +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter fun FriendRecordEntity.toModel(): FriendRecord = FriendRecord( isChecked = isChecked, - createdAt = createdAt, + createdAt = createdAt.toShortDate(), ) + +private fun String.toShortDate(): String { + val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + val outputFormatter = DateTimeFormatter.ofPattern("yy.MM.dd") + + val dateTime = LocalDateTime.parse(this, inputFormatter) + return dateTime.format(outputFormatter) +} diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt index cbb7bfd9..7651da94 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt @@ -1,6 +1,5 @@ package com.alarmy.near.data.repository -import android.util.Log import com.alarmy.near.data.mapper.toModel import com.alarmy.near.data.mapper.toRequest import com.alarmy.near.model.Friend @@ -28,7 +27,6 @@ class DefaultFriendRepository override fun fetchMonthlyFriends(): Flow> = flow { - Log.d("test", "repository") emit( friendService.fetchMonthlyFriends().map { it.toModel() diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index 7ab26c44..e2736dfd 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -57,10 +57,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R import com.alarmy.near.model.ContactFrequency import com.alarmy.near.model.Friend +import com.alarmy.near.model.FriendRecord import com.alarmy.near.model.Relation import com.alarmy.near.model.ReminderInterval import com.alarmy.near.presentation.feature.friendprofile.component.CallButton import com.alarmy.near.presentation.feature.friendprofile.component.MessageButton +import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendShipRecordState import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendState import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar import com.alarmy.near.presentation.ui.component.button.NearSolidTypeButton @@ -79,8 +81,10 @@ fun FriendProfileRoute( onClickMessageButton: (phoneNumber: String) -> Unit = {}, ) { val friendState = viewModel.friendFlow.collectAsStateWithLifecycle() + val friendShipRecordState = viewModel.friendShipRecordStateFlow.collectAsStateWithLifecycle() FriendProfileScreen( friendState = friendState.value, + friendShipRecordState = friendShipRecordState.value, onClickBackButton = onClickBackButton, onEditFriendInfo = onEditFriendInfo, onClickCallButton = onClickCallButton, @@ -92,6 +96,7 @@ fun FriendProfileRoute( fun FriendProfileScreen( modifier: Modifier = Modifier, friendState: FriendState, + friendShipRecordState: FriendShipRecordState, onClickBackButton: () -> Unit = {}, onEditFriendInfo: (Friend) -> Unit = {}, onClickCallButton: (phoneNumber: String) -> Unit = {}, @@ -319,7 +324,7 @@ fun FriendProfileScreen( if (currentTabPosition.intValue == 0) { ProfileTab(friend = friend) } else { - RecordTab() + RecordTab(friendShipRecordState = friendShipRecordState) } } NearSolidTypeButton( @@ -390,7 +395,10 @@ private fun ProfileTab( } @Composable -private fun RecordTab(modifier: Modifier = Modifier) { +private fun RecordTab( + modifier: Modifier = Modifier, + friendShipRecordState: FriendShipRecordState, +) { Column(modifier = modifier.padding(horizontal = 24.dp)) { Spacer(modifier = Modifier.height(24.dp)) Text( @@ -398,22 +406,38 @@ private fun RecordTab(modifier: Modifier = Modifier) { style = NearTheme.typography.B2_14_BOLD, color = NearTheme.colors.BLACK_1A1A1A, ) - Spacer(modifier = Modifier.height(13.dp)) - - LazyVerticalGrid( - GridCells.Fixed(3), - verticalArrangement = Arrangement.spacedBy(24.dp), - contentPadding = PaddingValues(bottom = 60.dp), - ) { - items(15) { - RecordItem() + if (friendShipRecordState.records.isEmpty()) { + Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(modifier = Modifier.height(60.dp)) + Image(painterResource(R.drawable.img_100_character_empty), contentDescription = null) + Spacer(modifier = Modifier.height(16.dp)) + Text( + "이번달은 챙길 사람이 없네요.", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + } + } else { + Spacer(modifier = Modifier.height(13.dp)) + LazyVerticalGrid( + GridCells.Fixed(3), + verticalArrangement = Arrangement.spacedBy(24.dp), + contentPadding = PaddingValues(bottom = 60.dp), + ) { + items(friendShipRecordState.records.size) { + RecordItem(friendRecord = friendShipRecordState.records[it], index = it) + } } } } } @Composable -private fun RecordItem(modifier: Modifier = Modifier) { +private fun RecordItem( + modifier: Modifier = Modifier, + index: Int, + friendRecord: FriendRecord, +) { Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, @@ -443,7 +467,7 @@ private fun RecordItem(modifier: Modifier = Modifier) { contentDescription = null, ) Text( - "11번째 챙김", + "${index + 1}번째 챙김", style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLUE01_5AA2E9, ) @@ -451,7 +475,7 @@ private fun RecordItem(modifier: Modifier = Modifier) { } Spacer(modifier = Modifier.height(8.dp)) Text( - "25.03.20", + friendRecord.createdAt, style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.GRAY01_888888, ) @@ -610,6 +634,16 @@ fun FriendProfileScreenPreview() { lastContactAt = "", ), ), + friendShipRecordState = + FriendShipRecordState( + records = + List(5) { + FriendRecord( + isChecked = true, + createdAt = "2023-11-1$it", + ) + }, + ), ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt index 07fc49e6..7800bca5 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt @@ -6,7 +6,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.alarmy.near.data.repository.FriendRepository +import com.alarmy.near.model.FriendRecord import com.alarmy.near.presentation.feature.friendprofile.navigation.RouteFriendProfile +import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendProfileUIEvent +import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendShipRecordState import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel @@ -17,6 +20,7 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import javax.inject.Inject @HiltViewModel @@ -27,13 +31,19 @@ class FriendProfileViewModel private val friendRepository: FriendRepository, ) : ViewModel() { private val friendId: String = savedStateHandle.toRoute().friendId - private val _errorEvent = Channel() - val errorEvent = _errorEvent.receiveAsFlow() + private val _uiEvent = Channel() + val uiEvent = _uiEvent.receiveAsFlow() private val _friendFlow: MutableStateFlow = MutableStateFlow(FriendState.Loading) val friendFlow: StateFlow = _friendFlow.asStateFlow() + private val _friendShipRecordStateFlow: MutableStateFlow = + MutableStateFlow(FriendShipRecordState(isLoading = true)) + val friendShipRecordStateFlow: StateFlow = + _friendShipRecordStateFlow.asStateFlow() + init { fetchFriend() + fetchFriendShipRecord() } fun fetchFriend() { @@ -42,12 +52,58 @@ class FriendProfileViewModel .onEach { friend -> _friendFlow.value = FriendState.Success(friend) }.catch { error -> - Log.d("test1", error.message.toString()) _friendFlow.value = FriendState.Error("데이터를 가져오는데 실패했습니다.") - _errorEvent.send(error) // UI에서 단발성 이벤트로도 쓸 수 있음 + _uiEvent.send(FriendProfileUIEvent.NetworkError) // UI에서 단발성 이벤트로도 쓸 수 있음 + }.launchIn(viewModelScope) + } + + fun fetchFriendShipRecord() { + friendRepository + .fetchFriendRecord(friendId) + .onEach { records -> + _friendShipRecordStateFlow.update { + it.copy( + records = records.filter { record -> record.isChecked }, + isLoading = false, + ) + } + }.catch { error -> + _friendShipRecordStateFlow.update { + it.copy( + isLoading = false, + ) + } + _uiEvent.send(FriendProfileUIEvent.NetworkError) // UI에서 단발성 이벤트로도 쓸 수 있음 }.launchIn(viewModelScope) } fun deleteFriend() { + friendRepository + .deleteFriend(friendId) + .onEach { + _uiEvent.send(FriendProfileUIEvent.DeleteFriendSuccess) + // event + }.catch { error -> + _uiEvent.send(FriendProfileUIEvent.NetworkError) // UI에서 단발성 이벤트로도 쓸 수 있음 + }.launchIn(viewModelScope) + } + + fun recordFriendShip() { + friendRepository + .recordContact(friendId) + .onEach { result -> + _uiEvent.send(FriendProfileUIEvent.RecordFriendShipSuccess) + _friendShipRecordStateFlow.update { recordState -> + recordState.copy( + records = + listOf( + FriendRecord(isChecked = true, createdAt = result), + ) + (recordState.records), + ) + } + // event + }.catch { error -> + _uiEvent.send(FriendProfileUIEvent.NetworkError) // UI에서 단발성 이벤트로도 쓸 수 있음 + }.launchIn(viewModelScope) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendProfileUIEvent.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendProfileUIEvent.kt new file mode 100644 index 00000000..c806c606 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendProfileUIEvent.kt @@ -0,0 +1,9 @@ +package com.alarmy.near.presentation.feature.friendprofile.uistate + +sealed interface FriendProfileUIEvent { + data object NetworkError : FriendProfileUIEvent + + data object DeleteFriendSuccess : FriendProfileUIEvent + + data object RecordFriendShipSuccess : FriendProfileUIEvent +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendShipRecordState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendShipRecordState.kt new file mode 100644 index 00000000..e5041ff5 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendShipRecordState.kt @@ -0,0 +1,8 @@ +package com.alarmy.near.presentation.feature.friendprofile.uistate + +import com.alarmy.near.model.FriendRecord + +data class FriendShipRecordState( + val records: List = emptyList(), + val isLoading: Boolean = false, +) From 7933609f279d42ee1513ab1b52d385cf6d1a22fc Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Wed, 27 Aug 2025 00:33:34 +0900 Subject: [PATCH 015/100] =?UTF-8?q?fix:=20=EC=B1=99=EA=B9=80=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20Entity=20=EC=A7=81=EB=A0=AC=ED=99=94=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/alarmy/near/network/response/FriendRecordEntity.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/FriendRecordEntity.kt b/Near/app/src/main/java/com/alarmy/near/network/response/FriendRecordEntity.kt index ccc9355b..577349c4 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/response/FriendRecordEntity.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/response/FriendRecordEntity.kt @@ -1,5 +1,8 @@ package com.alarmy.near.network.response +import kotlinx.serialization.Serializable + +@Serializable data class FriendRecordEntity( val isChecked: Boolean, val createdAt: String, From 7e718b675db263c4a0d25670ba892e92e3d1783e Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Wed, 27 Aug 2025 01:41:17 +0900 Subject: [PATCH 016/100] =?UTF-8?q?feat:=20=EC=B1=99=EA=B9=80=20=EB=B0=8F?= =?UTF-8?q?=20=EC=B9=9C=EA=B5=AC=20=EC=82=AD=EC=A0=9C=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/alarmy/near/model/Friend.kt | 18 ++++- .../com/alarmy/near/model/FriendRecord.kt | 2 +- .../friendprofile/FriendProfileScreen.kt | 75 ++++++++++++++++++- .../friendprofile/FriendProfileViewModel.kt | 42 +++++++++-- .../uistate/FriendProfileUIEvent.kt | 4 +- .../drawable/img_100_character_success.xml | 33 ++++++++ 6 files changed, 161 insertions(+), 13 deletions(-) create mode 100644 Near/app/src/main/res/drawable/img_100_character_success.xml diff --git a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt index f4221203..33917147 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt @@ -1,5 +1,9 @@ package com.alarmy.near.model +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale + data class Friend( val friendId: String, val imageUrl: String?, @@ -10,8 +14,18 @@ data class Friend( val anniversaryList: List, val memo: String?, val phone: String?, - val lastContactAt: String?, -) + val lastContactAt: String?, // "2025-07-16" +) { + val isContactedToday: Boolean + get() = lastContactAt?.isToday() ?: false + + private fun String.isToday(): Boolean { + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.KOREA) + val targetDate = LocalDate.parse(this, formatter) + val today = LocalDate.now() + return targetDate == today + } +} data class ContactFrequency( val reminderInterval: ReminderInterval, diff --git a/Near/app/src/main/java/com/alarmy/near/model/FriendRecord.kt b/Near/app/src/main/java/com/alarmy/near/model/FriendRecord.kt index a7754e22..2d6a7459 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/FriendRecord.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/FriendRecord.kt @@ -2,5 +2,5 @@ package com.alarmy.near.model data class FriendRecord( val isChecked: Boolean, - val createdAt: String, + val createdAt: String, // ex) 25.11.12 ) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index e2736dfd..a7eec3b5 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.TabRow import androidx.compose.material3.TabRowDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -43,6 +44,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.res.painterResource @@ -52,6 +54,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R @@ -62,12 +65,15 @@ import com.alarmy.near.model.Relation import com.alarmy.near.model.ReminderInterval import com.alarmy.near.presentation.feature.friendprofile.component.CallButton import com.alarmy.near.presentation.feature.friendprofile.component.MessageButton +import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendProfileUIEvent import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendShipRecordState import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendState import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar import com.alarmy.near.presentation.ui.component.button.NearSolidTypeButton import com.alarmy.near.presentation.ui.extension.onNoRippleClick import com.alarmy.near.presentation.ui.theme.NearTheme +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import java.time.LocalDate import java.time.format.DateTimeFormatter @@ -79,16 +85,43 @@ fun FriendProfileRoute( onEditFriendInfo: (Friend) -> Unit = {}, onClickCallButton: (phoneNumber: String) -> Unit = {}, onClickMessageButton: (phoneNumber: String) -> Unit = {}, + onDeleteFriendSuccess: (friendId: String) -> Unit = {}, ) { val friendState = viewModel.friendFlow.collectAsStateWithLifecycle() val friendShipRecordState = viewModel.friendShipRecordStateFlow.collectAsStateWithLifecycle() + val recordSuccessDialogState = remember { mutableStateOf(false) } + LaunchedEffect(viewModel.uiEvent) { + launch { + viewModel.uiEvent.collect { event -> + when (event) { + is FriendProfileUIEvent.NetworkError -> { + onShowErrorSnackBar(IllegalStateException("네트워크 에러가 발생했습니다.")) + } + + is FriendProfileUIEvent.DeleteFriendSuccess -> { + onDeleteFriendSuccess(event.friendId) + } + + is FriendProfileUIEvent.RecordFriendShipSuccess -> { + recordSuccessDialogState.value = true + } + } + } + } + } FriendProfileScreen( friendState = friendState.value, friendShipRecordState = friendShipRecordState.value, + recordSuccessDialogState = recordSuccessDialogState.value, onClickBackButton = onClickBackButton, onEditFriendInfo = onEditFriendInfo, onClickCallButton = onClickCallButton, onClickMessageButton = onClickMessageButton, + onRecordFriendShip = viewModel::onRecordFriendShip, + onDeleteFriend = viewModel::onDeleteFriend, + onDismissRecordSuccessDialog = { + recordSuccessDialogState.value = false + }, ) } @@ -97,10 +130,14 @@ fun FriendProfileScreen( modifier: Modifier = Modifier, friendState: FriendState, friendShipRecordState: FriendShipRecordState, + recordSuccessDialogState: Boolean = false, onClickBackButton: () -> Unit = {}, onEditFriendInfo: (Friend) -> Unit = {}, onClickCallButton: (phoneNumber: String) -> Unit = {}, onClickMessageButton: (phoneNumber: String) -> Unit = {}, + onRecordFriendShip: (friendId: String) -> Unit = {}, + onDeleteFriend: (friendId: String) -> Unit = {}, + onDismissRecordSuccessDialog: () -> Unit = {}, ) { val density = LocalDensity.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } @@ -122,6 +159,39 @@ fun FriendProfileScreen( .fillMaxSize() .background(NearTheme.colors.WHITE_FFFFFF), ) { + if (recordSuccessDialogState) { + LaunchedEffect(true) { + if (recordSuccessDialogState) { + delay(2000L) + onDismissRecordSuccessDialog() + } + } + Dialog(onDismissRequest = onDismissRecordSuccessDialog) { + Column( + modifier = + Modifier + .width(255.dp) + .height(186.dp) + .background( + color = NearTheme.colors.WHITE_FFFFFF, + shape = RoundedCornerShape(16.dp), + ), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painterResource(R.drawable.img_100_character_success), + contentDescription = "", + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + "더 가까워졌어요!", + style = NearTheme.typography.B1_16_BOLD, + color = Color(0xff222222), + ) + } + } + } NearTopAppbar( title = stringResource(R.string.friend_profile_title), onClickBackButton = onClickBackButton, @@ -157,6 +227,7 @@ fun FriendProfileScreen( ) DropdownMenuItem( onClick = { + onDeleteFriend(friend.friendId) dropdownState.value = false }, text = { @@ -334,8 +405,8 @@ fun FriendProfileScreen( .padding(horizontal = 20.dp) .align(Alignment.BottomCenter), contentPadding = PaddingValues(vertical = 17.dp), - enabled = true, - onClick = {}, + enabled = friend.isContactedToday.not(), + onClick = { onRecordFriendShip(friend.friendId) }, text = stringResource(R.string.friend_profile_record_button_text), ) } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt index 7800bca5..4982e655 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt @@ -1,6 +1,5 @@ package com.alarmy.near.presentation.feature.friendprofile -import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -21,6 +20,9 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale import javax.inject.Inject @HiltViewModel @@ -77,33 +79,59 @@ class FriendProfileViewModel }.launchIn(viewModelScope) } - fun deleteFriend() { + fun onDeleteFriend(friendId: String) { friendRepository .deleteFriend(friendId) .onEach { - _uiEvent.send(FriendProfileUIEvent.DeleteFriendSuccess) + _uiEvent.send(FriendProfileUIEvent.DeleteFriendSuccess(friendId)) // event }.catch { error -> _uiEvent.send(FriendProfileUIEvent.NetworkError) // UI에서 단발성 이벤트로도 쓸 수 있음 }.launchIn(viewModelScope) } - fun recordFriendShip() { + fun onRecordFriendShip(friendId: String) { friendRepository - .recordContact(friendId) - .onEach { result -> + .recordContact(friendId) // 내 현재 시간 가져와서 + .onEach { _ -> _uiEvent.send(FriendProfileUIEvent.RecordFriendShipSuccess) _friendShipRecordStateFlow.update { recordState -> recordState.copy( records = listOf( - FriendRecord(isChecked = true, createdAt = result), + FriendRecord( + isChecked = true, + createdAt = getTodayShortFormat(), + ), ) + (recordState.records), ) } + if (friendFlow.value is FriendState.Success) { + _friendFlow.update { + (it as FriendState.Success).copy( + friend = + it.friend.copy( + lastContactAt = getTodayDashFormat(), + ), + ) + } + } + // event }.catch { error -> _uiEvent.send(FriendProfileUIEvent.NetworkError) // UI에서 단발성 이벤트로도 쓸 수 있음 }.launchIn(viewModelScope) } + + private fun getTodayShortFormat(): String { + val today = LocalDate.now() + val formatter = DateTimeFormatter.ofPattern("yy.MM.dd", Locale.KOREA) + return today.format(formatter) + } + + private fun getTodayDashFormat(): String { + val today = LocalDate.now() + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.KOREA) + return today.format(formatter) + } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendProfileUIEvent.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendProfileUIEvent.kt index c806c606..87133fef 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendProfileUIEvent.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendProfileUIEvent.kt @@ -3,7 +3,9 @@ package com.alarmy.near.presentation.feature.friendprofile.uistate sealed interface FriendProfileUIEvent { data object NetworkError : FriendProfileUIEvent - data object DeleteFriendSuccess : FriendProfileUIEvent + data class DeleteFriendSuccess( + val friendId: String, + ) : FriendProfileUIEvent data object RecordFriendShipSuccess : FriendProfileUIEvent } diff --git a/Near/app/src/main/res/drawable/img_100_character_success.xml b/Near/app/src/main/res/drawable/img_100_character_success.xml new file mode 100644 index 00000000..db210bef --- /dev/null +++ b/Near/app/src/main/res/drawable/img_100_character_success.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + From 5263fb3960918db4f16af12f06fd17809d9d4963 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Wed, 27 Aug 2025 19:23:05 +0900 Subject: [PATCH 017/100] =?UTF-8?q?feat:=20Friend=20Navigation=20=EC=9D=B8?= =?UTF-8?q?=EC=9E=90=20=EC=A0=84=EB=8B=AC=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?Serializable=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Near/app/src/main/java/com/alarmy/near/model/Friend.kt | 4 ++++ .../navigation/FriendProfileNavigation.kt | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt index 33917147..1acfe62a 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt @@ -1,9 +1,11 @@ package com.alarmy.near.model +import kotlinx.serialization.Serializable import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.Locale +@Serializable data class Friend( val friendId: String, val imageUrl: String?, @@ -27,11 +29,13 @@ data class Friend( } } +@Serializable data class ContactFrequency( val reminderInterval: ReminderInterval, val dayOfWeek: String, ) +@Serializable data class Anniversary( val id: Int, val title: String, diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt index d7521c67..7fa1db3e 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt @@ -4,11 +4,14 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable +import com.alarmy.near.model.Friend import com.alarmy.near.presentation.feature.friendprofileedittor.FriendProfileEditorRoute import kotlinx.serialization.Serializable @Serializable -object RouteFriendProfileEditor +data class RouteFriendProfileEditor( + val friend: Friend, +) fun NavController.navigateToFriendProfileEditor(navOptions: NavOptions) { navigate(RouteFriendProfileEditor, navOptions) From 2c03a146d14e4964affd62f8ed23c4f7d4daae36 Mon Sep 17 00:00:00 2001 From: stopstone Date: Thu, 28 Aug 2025 17:55:46 +0900 Subject: [PATCH 018/100] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로그인 화면 UI 구성 - 카카오 로그인 버튼 추가 - Near 로고 및 타이틀 이미지 추가 - 관련 문자열 리소스 추가 - 라이트/다크 모드에 따른 상태바 아이콘 색상 조정 - 현재는 라이트모드로 고정 --- .../presentation/feature/login/LoginScreen.kt | 104 ++++++++++++++++++ .../near/presentation/ui/theme/Theme.kt | 18 +++ .../src/main/res/drawable/btn_kakao_login.xml | 21 ++++ .../main/res/drawable/ic_near_logo_title.xml | 18 +++ Near/app/src/main/res/values/strings.xml | 6 + 5 files changed, 167 insertions(+) create mode 100644 Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt create mode 100644 Near/app/src/main/res/drawable/btn_kakao_login.xml create mode 100644 Near/app/src/main/res/drawable/ic_near_logo_title.xml diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt new file mode 100644 index 00000000..bc258c3d --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt @@ -0,0 +1,104 @@ +package com.alarmy.near.presentation.feature.login + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.alarmy.near.R +import com.alarmy.near.presentation.ui.theme.NearTheme + +@Composable +fun LoginScreen() { + Column( + modifier = + Modifier + .fillMaxSize() + .systemBarsPadding(), + ) { + LoginIntroductionSection(modifier = Modifier.fillMaxWidth()) + + Spacer(modifier = Modifier.weight(1f)) + + KakaoLoginButton() + } +} + +@Composable +private fun LoginIntroductionSection(modifier: Modifier = Modifier) { + Spacer(modifier = Modifier.height(LoginScreenConstants.TOP_SPACING.dp)) + + Image( + modifier = modifier.size(LoginScreenConstants.LOGO_SIZE.dp), + alignment = Alignment.Center, + painter = painterResource(R.drawable.img_40_character), + contentDescription = stringResource(R.string.near_logo), + ) + + Image( + modifier = modifier.wrapContentSize(Alignment.Center), + alignment = Alignment.Center, + painter = painterResource(R.drawable.ic_near_logo_title), + contentDescription = stringResource(R.string.near_logo_title), + ) + + Spacer(modifier = Modifier.size(LoginScreenConstants.DESCRIPTION_SPACING.dp)) + + Text( + modifier = modifier.wrapContentSize(Alignment.Center), + text = stringResource(R.string.login_near_description), + style = NearTheme.typography.B1_16_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) +} + +@Composable +private fun ColumnScope.KakaoLoginButton() { + Image( + modifier = + Modifier + .wrapContentSize() + .align(Alignment.CenterHorizontally) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + // TODO 카카오 로그인 구현 + }, + painter = painterResource(R.drawable.btn_kakao_login), + contentDescription = stringResource(R.string.login_kakao_login_button_text), + ) + + Spacer(modifier = Modifier.size(LoginScreenConstants.BOTTOM_SPACING.dp)) +} + +private object LoginScreenConstants { + const val TOP_SPACING = 170 + const val LOGO_SIZE = 160 + const val DESCRIPTION_SPACING = 12 + const val BOTTOM_SPACING = 96 +} + +@Preview(showBackground = true) +@Composable +fun LoginScreenPreview() { + NearTheme { + LoginScreen() + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Theme.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Theme.kt index b6167e82..5737bacc 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Theme.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Theme.kt @@ -1,9 +1,13 @@ package com.alarmy.near.presentation.ui.theme +import android.app.Activity import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat val LocalCustomColors = staticCompositionLocalOf { @@ -30,6 +34,20 @@ fun NearTheme( LocalCustomTypography provides Typography, content = content, ) + +/* 스크린에서 상태바 아이콘 색상 +* */ + val view = LocalView.current +// val isDarkTheme = isSystemInDarkTheme() 시스템 다크 모드 여부를 Boolean으로 반환 + val isDarkTheme = false + + SideEffect { + if (!view.isInEditMode) { + val window = (view.context as Activity).window + val insetsController = WindowCompat.getInsetsController(window, view) + insetsController.isAppearanceLightStatusBars = !isDarkTheme + } + } } object NearTheme { diff --git a/Near/app/src/main/res/drawable/btn_kakao_login.xml b/Near/app/src/main/res/drawable/btn_kakao_login.xml new file mode 100644 index 00000000..c477cf1f --- /dev/null +++ b/Near/app/src/main/res/drawable/btn_kakao_login.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/Near/app/src/main/res/drawable/ic_near_logo_title.xml b/Near/app/src/main/res/drawable/ic_near_logo_title.xml new file mode 100644 index 00000000..4a457eac --- /dev/null +++ b/Near/app/src/main/res/drawable/ic_near_logo_title.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml index 3606877d..f6daf58a 100644 --- a/Near/app/src/main/res/values/strings.xml +++ b/Near/app/src/main/res/values/strings.xml @@ -7,6 +7,12 @@ 뒤로 가기 메뉴 + + Near 로고 + Near 타이틀 + 소중한 사람들과 더 가까워지는 시간 + 카카오 로그인 버튼 + MY 이번달 챙길 사람 From bd6ac87ac5a931f5a735ca8978b6aa71e23cbb64 Mon Sep 17 00:00:00 2001 From: stopstone Date: Thu, 28 Aug 2025 19:07:31 +0900 Subject: [PATCH 019/100] =?UTF-8?q?feat:=20DataStore=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Near/app/build.gradle.kts | 3 +++ Near/gradle/libs.versions.toml | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/Near/app/build.gradle.kts b/Near/app/build.gradle.kts index bd42f1a5..8a072706 100644 --- a/Near/app/build.gradle.kts +++ b/Near/app/build.gradle.kts @@ -88,6 +88,9 @@ dependencies { implementation(libs.navigation.compose) // Serialization implementation(libs.kotlin.serialization.json) + // DataStore + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.datastore.core) } fun getProperty(propertyKey: String): String = gradleLocalProperties(rootDir, providers).getProperty(propertyKey) diff --git a/Near/gradle/libs.versions.toml b/Near/gradle/libs.versions.toml index 9c13f8fc..978f5b9e 100644 --- a/Near/gradle/libs.versions.toml +++ b/Near/gradle/libs.versions.toml @@ -25,9 +25,13 @@ navigationVersion = "2.9.2" kotlinSerializationVersion = "1.9.0" # OkHttp okHttp = "5.1.0" +# DataStore +datastorePreferences = "1.1.7" +datastoreCore = "1.1.7" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -55,6 +59,7 @@ navigation-compose = { group = "androidx.navigation", name = "navigation-compose kotlin-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinSerializationVersion" } logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okHttp" } retrofit-kotlin-serialization-converter = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofitVersion" } +androidx-datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastoreCore" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 6114ae3892b1e5ccc4560b8b3b12759f983dd484 Mon Sep 17 00:00:00 2001 From: stopstone Date: Thu, 28 Aug 2025 19:46:38 +0900 Subject: [PATCH 020/100] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20S?= =?UTF-8?q?DK=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Near/app/build.gradle.kts | 7 ++++++- Near/app/src/main/AndroidManifest.xml | 5 +++++ Near/gradle/libs.versions.toml | 3 +++ Near/settings.gradle.kts | 1 + 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Near/app/build.gradle.kts b/Near/app/build.gradle.kts index 8a072706..bb4161ab 100644 --- a/Near/app/build.gradle.kts +++ b/Near/app/build.gradle.kts @@ -32,10 +32,12 @@ android { ) buildConfigField("String", "NEAR_URL", getProperty("NEAR_PROD_URL")) buildConfigField("String", "TEMP_TOKEN", getProperty("TEMP_TOKEN")) // TODO 추후 삭제 필요 + manifestPlaceholders["kakaoAppKey"] = getProperty("KAKAO_NATIVE_APP_KEY").replace("\"", "") } debug { buildConfigField("String", "NEAR_URL", getProperty("NEAR_DEV_URL")) - buildConfigField("String", "TEMP_TOKEN", getProperty("TEMP_TOKEN")) // TODO 추후 삭제 필요 + buildConfigField("String", "TEMP_TOKEN", getProperty("TEMP_TOKEN")) // TODO 추후 삭제 필요 + manifestPlaceholders["kakaoAppKey"] = getProperty("KAKAO_NATIVE_APP_KEY").replace("\"", "") } } compileOptions { @@ -91,6 +93,9 @@ dependencies { // DataStore implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.core) + + // Kakao Module + implementation(libs.v2.all) } fun getProperty(propertyKey: String): String = gradleLocalProperties(rootDir, providers).getProperty(propertyKey) diff --git a/Near/app/src/main/AndroidManifest.xml b/Near/app/src/main/AndroidManifest.xml index 08b99e2f..43dcf70d 100644 --- a/Near/app/src/main/AndroidManifest.xml +++ b/Near/app/src/main/AndroidManifest.xml @@ -15,6 +15,11 @@ android:supportsRtl="true" android:theme="@style/Theme.Near" tools:targetApi="31"> + + + Date: Thu, 28 Aug 2025 20:05:25 +0900 Subject: [PATCH 021/100] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EB=84=A4?= =?UTF-8?q?=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/feature/login/LoginScreen.kt | 46 +++++++++++++++++-- .../login/navigation/LoginNavigation.kt | 28 +++++++++++ .../presentation/feature/main/NearNavHost.kt | 40 +++++++++++++--- 3 files changed, 102 insertions(+), 12 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/presentation/feature/login/navigation/LoginNavigation.kt 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 bc258c3d..4c072ef2 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 @@ -14,6 +14,8 @@ import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -21,11 +23,35 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R import com.alarmy.near.presentation.ui.theme.NearTheme @Composable -fun LoginScreen() { +internal fun LoginRoute( + onNavigateToHome: () -> Unit, + viewModel: LoginViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.loginSuccessEvent.collect { + onNavigateToHome() + } + } + + LoginScreen( + uiState = uiState, + performKakaoLogin = viewModel::performKakaoLogin, + ) +} + +@Composable +fun LoginScreen( + uiState: LoginUiState, + performKakaoLogin: () -> Unit, +) { Column( modifier = Modifier @@ -36,7 +62,10 @@ fun LoginScreen() { Spacer(modifier = Modifier.weight(1f)) - KakaoLoginButton() + KakaoLoginButton( + enable = uiState.isLoading, + onLoginClick = performKakaoLogin, + ) } } @@ -69,7 +98,10 @@ private fun LoginIntroductionSection(modifier: Modifier = Modifier) { } @Composable -private fun ColumnScope.KakaoLoginButton() { +private fun ColumnScope.KakaoLoginButton( + enable: Boolean, + onLoginClick: () -> Unit, +) { Image( modifier = Modifier @@ -78,8 +110,9 @@ private fun ColumnScope.KakaoLoginButton() { .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, + enabled = !enable, ) { - // TODO 카카오 로그인 구현 + onLoginClick() }, painter = painterResource(R.drawable.btn_kakao_login), contentDescription = stringResource(R.string.login_kakao_login_button_text), @@ -99,6 +132,9 @@ private object LoginScreenConstants { @Composable fun LoginScreenPreview() { NearTheme { - LoginScreen() + LoginScreen( + uiState = LoginUiState(), + performKakaoLogin = {}, + ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/navigation/LoginNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/navigation/LoginNavigation.kt new file mode 100644 index 00000000..f2d70ce7 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/navigation/LoginNavigation.kt @@ -0,0 +1,28 @@ +package com.alarmy.near.presentation.feature.login.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.alarmy.near.presentation.feature.login.LoginRoute +import kotlinx.serialization.Serializable + +@Serializable +object RouteLogin + +// 로그인 화면으로 이동하는 확장 함수 +fun NavController.navigateToLogin(navOptions: NavOptions? = null) { + navigate(RouteLogin, navOptions) +} + +// 로그인 화면 NavGraph 정의 +fun NavGraphBuilder.loginNavGraph( + onNavigateToHome: () -> Unit, + onShowErrorSnackBar: (throwable: Throwable?) -> Unit, +) { + composable { + LoginRoute( + onNavigateToHome = onNavigateToHome, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt index 94f9e995..3b7ad4b9 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 @@ -9,6 +9,9 @@ import com.alarmy.near.presentation.feature.friendprofile.navigation.navigateToF import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.friendProfileEditorNavGraph import com.alarmy.near.presentation.feature.home.navigation.RouteHome 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 @Composable internal fun NearNavHost( @@ -22,14 +25,21 @@ internal fun NearNavHost( NavHost( modifier = modifier, navController = navController, - startDestination = RouteHome, + startDestination = RouteLogin, ) { - friendProfileNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = { - navController.popBackStack() - }) - friendProfileEditorNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = { - navController.popBackStack() - }) + // 로그인 화면 NavGraph + loginNavGraph( + onShowErrorSnackBar = onShowSnackbar, + onNavigateToHome = { + navController.navigateToHome( + navOptions = androidx.navigation.navOptions { + popUpTo(RouteLogin) { inclusive = true } + } + ) + } + ) + + // 홈 화면 NavGraph homeNavGraph( onShowErrorSnackBar = onShowSnackbar, onContactClick = { contactId -> @@ -39,5 +49,21 @@ internal fun NearNavHost( onAlarmClick = {}, onAddContactClick = {}, ) + + // 친구 프로필 화면 NavGraph + friendProfileNavGraph( + onShowErrorSnackBar = onShowSnackbar, + onClickBackButton = { + navController.popBackStack() + } + ) + + // 친구 프로필 편집 화면 NavGraph + friendProfileEditorNavGraph( + onShowErrorSnackBar = onShowSnackbar, + onClickBackButton = { + navController.popBackStack() + } + ) } } From 0d705674551f71cfb074d36d824a357d0b16f577 Mon Sep 17 00:00:00 2001 From: stopstone Date: Thu, 28 Aug 2025 20:08:52 +0900 Subject: [PATCH 022/100] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20API=20=EC=9A=94=EC=B2=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthRepository 및 AuthService 인터페이스 정의 - DataStore를 사용한 토큰 관리 및 AuthRepositoryImpl 구현 - 소셜 로그인 요청/응답 데이터 클래스(SocialLoginRequest, LoginResponse, LoginResult) 추가 - 로그인 관련 ProviderType enum 정의 - LoginViewModel에 소셜 로그인 로직 및 UI 상태 관리 추가 - DI 모듈(RepositoryModule, ServiceModule)에 관련 의존성 주입 설정 --- .../alarmy/near/data/di/RepositoryModule.kt | 6 + .../near/data/repository/AuthRepository.kt | 39 +++++ .../data/repository/AuthRepositoryImpl.kt | 144 ++++++++++++++++++ .../java/com/alarmy/near/model/LoginResult.kt | 8 + .../com/alarmy/near/model/ProviderType.kt | 6 + .../alarmy/near/network/di/ServiceModule.kt | 5 + .../network/request/SocialLoginRequest.kt | 18 +++ .../near/network/response/LoginResponse.kt | 22 +++ .../near/network/service/AuthService.kt | 17 +++ .../feature/login/LoginViewModel.kt | 110 +++++++++++++ 10 files changed, 375 insertions(+) create mode 100644 Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/model/LoginResult.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/model/ProviderType.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/network/request/SocialLoginRequest.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/network/response/LoginResponse.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/network/service/AuthService.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt diff --git a/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt b/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt index 3dff6205..5d73c37f 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt @@ -1,5 +1,7 @@ package com.alarmy.near.data.di +import com.alarmy.near.data.repository.AuthRepository +import com.alarmy.near.data.repository.AuthRepositoryImpl import com.alarmy.near.data.repository.DefaultFriendRepository import com.alarmy.near.data.repository.ExampleRepository import com.alarmy.near.data.repository.ExampleRepositoryImpl @@ -20,4 +22,8 @@ interface RepositoryModule { @Binds @Singleton abstract fun bindFriendRepository(friendRepository: DefaultFriendRepository): FriendRepository + + @Binds + @Singleton + abstract fun bindAuthRepository(authRepositoryImpl: AuthRepositoryImpl): AuthRepository } diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt new file mode 100644 index 00000000..3a8d14fe --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt @@ -0,0 +1,39 @@ +package com.alarmy.near.data.repository + +import com.alarmy.near.model.LoginResult +import com.alarmy.near.model.ProviderType +import kotlinx.coroutines.flow.Flow + +interface AuthRepository { + /** + * 소셜 로그인 수행 + */ + suspend fun socialLogin( + accessToken: String, + providerType: ProviderType, + ): LoginResult + + /** + * 로그아웃 수행 + * 로컬에 저장된 토큰을 삭제 + */ + suspend fun logout() + + /** + * 로그인 상태 확인 + * 저장된 토큰이 있는지 확인 + */ + suspend fun isLoggedIn(): Boolean + + /** + * 현재 사용자 토큰 가져오기 + * 저장된 Access Token 반환 + */ + suspend fun getCurrentUserToken(): String? + + /** + * 로그인 상태 관찰 + * 토큰 변화를 실시간으로 관찰 + */ + fun observeLoginStatus(): Flow +} diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 00000000..585c46eb --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt @@ -0,0 +1,144 @@ +package com.alarmy.near.data.repository + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.alarmy.near.model.LoginResult +import com.alarmy.near.model.ProviderType +import com.alarmy.near.network.request.SocialLoginRequest +import com.alarmy.near.network.service.AuthService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import retrofit2.HttpException +import javax.inject.Inject +import javax.inject.Singleton + +// DataStore 확장 프로퍼티 +private val Context.dataStore: DataStore by preferencesDataStore(name = "auth_preferences") + +/** + * DataStore 제공 모듈 + */ +@Module +@InstallIn(SingletonComponent::class) +object DataStoreModule { + @Provides + @Singleton + fun provideDataStore( + @ApplicationContext context: Context, + ): DataStore = context.dataStore +} + +class AuthRepositoryImpl + @Inject + constructor( + private val authService: AuthService, + private val dataStore: DataStore, + ) : AuthRepository { + private val accessTokenKey = stringPreferencesKey("access_token") + private val refreshTokenKey = stringPreferencesKey("refresh_token") + + override suspend fun socialLogin( + accessToken: String, + providerType: ProviderType, + ): LoginResult = + try { + val request = + SocialLoginRequest( + accessToken = accessToken, + providerType = providerType.name, + ) + + val response = authService.socialLogin(request) + + // 토큰 저장 + saveTokens( + accessToken = response.accessToken, + refreshToken = response.refreshTokenInfo?.token, + ) + + LoginResult( + isSuccess = true, + accessToken = response.accessToken, + refreshToken = response.refreshTokenInfo?.token, + ) + } catch (exception: HttpException) { + val errorMessage = + when (exception.code()) { + 400 -> "잘못된 요청입니다" + 401 -> "인증에 실패했습니다" + 403 -> "접근이 거부되었습니다" + 500 -> "서버에 문제가 발생했습니다" + else -> "로그인 중 오류가 발생했습니다" + } + + LoginResult( + isSuccess = false, + errorMessage = errorMessage, + ) + } catch (exception: Exception) { + val errorMessage = exception.message ?: "알 수 없는 오류가 발생했습니다" + LoginResult( + isSuccess = false, + errorMessage = errorMessage, + ) + } + + override suspend fun logout() { + try { + clearTokens() + } catch (exception: Exception) { + throw exception + } + } + + override suspend fun isLoggedIn(): Boolean = + try { + val token = getCurrentUserToken() + val isLoggedIn = !token.isNullOrBlank() + isLoggedIn + } catch (exception: Exception) { + false + } + + override suspend fun getCurrentUserToken(): String? = + try { + val token = dataStore.data.first()[accessTokenKey] + token + } catch (exception: Exception) { + null + } + + override fun observeLoginStatus(): Flow = + dataStore.data.map { preferences -> + !preferences[accessTokenKey].isNullOrBlank() + } + + private suspend fun saveTokens( + accessToken: String, + refreshToken: String?, + ) { + dataStore.edit { preferences -> + preferences[accessTokenKey] = accessToken + refreshToken?.let { + preferences[refreshTokenKey] = it + } + } + } + + private suspend fun clearTokens() { + dataStore.edit { preferences -> + preferences.remove(accessTokenKey) + preferences.remove(refreshTokenKey) + } + } + } diff --git a/Near/app/src/main/java/com/alarmy/near/model/LoginResult.kt b/Near/app/src/main/java/com/alarmy/near/model/LoginResult.kt new file mode 100644 index 00000000..e38df6cd --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/model/LoginResult.kt @@ -0,0 +1,8 @@ +package com.alarmy.near.model + +data class LoginResult( + val isSuccess: Boolean, + val accessToken: String? = null, + val refreshToken: String? = null, + val errorMessage: String? = null, +) diff --git a/Near/app/src/main/java/com/alarmy/near/model/ProviderType.kt b/Near/app/src/main/java/com/alarmy/near/model/ProviderType.kt new file mode 100644 index 00000000..9d4f0fbe --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/model/ProviderType.kt @@ -0,0 +1,6 @@ +package com.alarmy.near.model + +enum class ProviderType { + KAKAO, + ETC, +} diff --git a/Near/app/src/main/java/com/alarmy/near/network/di/ServiceModule.kt b/Near/app/src/main/java/com/alarmy/near/network/di/ServiceModule.kt index a6ef4802..6324fd18 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/di/ServiceModule.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/di/ServiceModule.kt @@ -1,5 +1,6 @@ package com.alarmy.near.network.di +import com.alarmy.near.network.service.AuthService import com.alarmy.near.network.service.FriendService import dagger.Module import dagger.Provides @@ -11,6 +12,10 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object ServiceModule { + @Provides + @Singleton + fun provideAuthService(retrofit: Retrofit): AuthService = retrofit.create(AuthService::class.java) + @Provides @Singleton fun provideFriendService(retrofit: Retrofit): FriendService = retrofit.create(FriendService::class.java) diff --git a/Near/app/src/main/java/com/alarmy/near/network/request/SocialLoginRequest.kt b/Near/app/src/main/java/com/alarmy/near/network/request/SocialLoginRequest.kt new file mode 100644 index 00000000..56a78fee --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/request/SocialLoginRequest.kt @@ -0,0 +1,18 @@ +package com.alarmy.near.network.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * 소셜 로그인 요청 데이터 클래스 + * + * @param accessToken 소셜 로그인 AccessToken + * @param providerType 소셜 로그인 Provider 타입 (KAKAO, APPLE 등) + */ +@Serializable +data class SocialLoginRequest( + @SerialName("accessToken") + val accessToken: String, + @SerialName("providerType") + val providerType: String, +) diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/LoginResponse.kt b/Near/app/src/main/java/com/alarmy/near/network/response/LoginResponse.kt new file mode 100644 index 00000000..9cdb1622 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/response/LoginResponse.kt @@ -0,0 +1,22 @@ +package com.alarmy.near.network.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// 소셜 로그인 응답 데이터 클래스 +@Serializable +data class LoginResponse( + @SerialName("accessToken") + val accessToken: String, + @SerialName("refreshTokenInfo") + val refreshTokenInfo: RefreshTokenInfo? = null, +) + +// 리프레시 토큰 정보 +@Serializable +data class RefreshTokenInfo( + @SerialName("token") + val token: String, + @SerialName("expiresAt") + val expiresAt: String, +) diff --git a/Near/app/src/main/java/com/alarmy/near/network/service/AuthService.kt b/Near/app/src/main/java/com/alarmy/near/network/service/AuthService.kt new file mode 100644 index 00000000..646adb59 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/service/AuthService.kt @@ -0,0 +1,17 @@ +package com.alarmy.near.network.service + +import com.alarmy.near.network.request.SocialLoginRequest +import com.alarmy.near.network.response.LoginResponse +import retrofit2.http.Body +import retrofit2.http.POST + +/** + * 인증 관련 API 서비스 + */ +interface AuthService { + // 소셜 로그인 API 호출 + @POST("/auth/social") + suspend fun socialLogin( + @Body request: SocialLoginRequest, + ): LoginResponse +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt new file mode 100644 index 00000000..479dfc4f --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt @@ -0,0 +1,110 @@ +package com.alarmy.near.presentation.feature.login + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.alarmy.near.data.repository.AuthRepository +import com.alarmy.near.model.ProviderType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class LoginViewModel + @Inject + constructor( + private val authRepository: AuthRepository, + ) : ViewModel() { + // UI 상태 관리 + private val _uiState = MutableStateFlow(LoginUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + // 에러 이벤트 관리 + private val _errorEvent = Channel() + val errorEvent = _errorEvent.receiveAsFlow() + + // 로그인 성공 이벤트 관리 + private val _loginSuccessEvent = Channel() + val loginSuccessEvent = _loginSuccessEvent.receiveAsFlow() + + /** + * 소셜 로그인 수행 + * + * @param accessToken 소셜 플랫폼에서 받은 Access Token + * @param providerType 소셜 로그인 제공자 타입 + */ + fun performSocialLogin( + accessToken: String, + providerType: com.alarmy.near.model.ProviderType, + ) { + viewModelScope.launch { + try { + updateLoadingState(isLoading = true) + val loginResult = authRepository.socialLogin(accessToken, providerType) + + updateLoadingState(isLoading = false) + + if (loginResult.isSuccess) { + _loginSuccessEvent.send(Unit) + } else { + val errorMsg = loginResult.errorMessage ?: "로그인에 실패했습니다" + _errorEvent.send(Exception(errorMsg)) + } + } catch (exception: Exception) { + updateLoadingState(isLoading = false) + _errorEvent.send(exception) + } + } + } + + /** + * 카카오 토큰으로 로그인 수행 + * UI에서 카카오 로그인을 완료한 후 토큰을 받아서 처리 + */ + fun performKakaoLogin(kakaoAccessToken: String) { + performSocialLogin(kakaoAccessToken, ProviderType.KAKAO) + } + + /** + * 로그인 상태 확인 + */ + fun checkLoginStatus() { + viewModelScope.launch { + try { + val isLoggedIn = authRepository.isLoggedIn() + if (isLoggedIn) { + _loginSuccessEvent.send(Unit) + } + } catch (exception: Exception) { + _errorEvent.send(exception) + } + } + } + + /** + * 로딩 상태 업데이트 + */ + private fun updateLoadingState(isLoading: Boolean) { + _uiState.value = _uiState.value.copy(isLoading = isLoading) + } + + /** + * 에러 상태 초기화 + */ + fun clearError() { + _uiState.value = _uiState.value.copy(hasError = false) + } + } + +/** + * 로그인 화면 UI 상태 + */ +data class LoginUiState( + val isLoading: Boolean = false, + val hasError: Boolean = false, +) From b92b913b3394df794a41c2d0d11f531fc05672e7 Mon Sep 17 00:00:00 2001 From: stopstone Date: Thu, 28 Aug 2025 20:40:08 +0900 Subject: [PATCH 023/100] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=84=9C=EB=B2=84=20API=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `build.gradle.kts` 파일에 카카오 네이티브 앱 키 추가 - `NearApplication.kt` 파일에 카카오 SDK 초기화 코드 추가 - `LoginScreen.kt` 파일에 카카오 로그인 로직 구현 및 UI 연동 - `AndroidManifest.xml` 파일에 카카오 로그인 콜백을 위한 Activity 및 intent-filter 추가 --- Near/app/build.gradle.kts | 4 +- Near/app/src/main/AndroidManifest.xml | 14 +++++ .../java/com/alarmy/near/NearApplication.kt | 11 +++- .../data/repository/AuthRepositoryImpl.kt | 1 + .../presentation/feature/login/LoginScreen.kt | 59 ++++++++++++++++++- .../feature/login/LoginViewModel.kt | 1 + 6 files changed, 87 insertions(+), 3 deletions(-) diff --git a/Near/app/build.gradle.kts b/Near/app/build.gradle.kts index bb4161ab..6ab76de2 100644 --- a/Near/app/build.gradle.kts +++ b/Near/app/build.gradle.kts @@ -32,11 +32,13 @@ android { ) buildConfigField("String", "NEAR_URL", getProperty("NEAR_PROD_URL")) buildConfigField("String", "TEMP_TOKEN", getProperty("TEMP_TOKEN")) // TODO 추후 삭제 필요 + buildConfigField("String", "KAKAO_NATIVE_APP_KEY", getProperty("KAKAO_NATIVE_APP_KEY")) manifestPlaceholders["kakaoAppKey"] = getProperty("KAKAO_NATIVE_APP_KEY").replace("\"", "") } debug { buildConfigField("String", "NEAR_URL", getProperty("NEAR_DEV_URL")) - buildConfigField("String", "TEMP_TOKEN", getProperty("TEMP_TOKEN")) // TODO 추후 삭제 필요 + buildConfigField("String", "TEMP_TOKEN", getProperty("TEMP_TOKEN")) // TODO 추후 삭제 필요 + buildConfigField("String", "KAKAO_NATIVE_APP_KEY", getProperty("KAKAO_NATIVE_APP_KEY")) manifestPlaceholders["kakaoAppKey"] = getProperty("KAKAO_NATIVE_APP_KEY").replace("\"", "") } } diff --git a/Near/app/src/main/AndroidManifest.xml b/Near/app/src/main/AndroidManifest.xml index 43dcf70d..b0ae0a68 100644 --- a/Near/app/src/main/AndroidManifest.xml +++ b/Near/app/src/main/AndroidManifest.xml @@ -20,6 +20,20 @@ + + + + + + + + + + + Unit, viewModel: LoginViewModel = hiltViewModel(), ) { + val context = LocalContext.current val uiState by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { @@ -41,12 +47,63 @@ internal fun LoginRoute( } } + /** + * 카카오 로그인 처리 함수 + */ + fun handleKakaoLogin() { + // 카카오톡으로 로그인 가능 여부 확인 + val isKakaoTalkAvailable = UserApiClient.instance.isKakaoTalkLoginAvailable(context) + + if (isKakaoTalkAvailable) { + // 카카오톡 앱이 설치되어 있으면 카카오톡으로 로그인 + UserApiClient.instance.loginWithKakaoTalk(context) { token, error -> + handleKakaoLoginResult(token, error, "카카오톡 앱", viewModel, context) + } + } else { + // 카카오톡 앱이 없으면 카카오계정으로 웹 로그인 + UserApiClient.instance.loginWithKakaoAccount(context) { token, error -> + handleKakaoLoginResult(token, error, "카카오계정 웹", viewModel, context) + } + } + } + LoginScreen( uiState = uiState, - performKakaoLogin = viewModel::performKakaoLogin, + performKakaoLogin = ::handleKakaoLogin, ) } +/** + * 카카오 로그인 결과 처리 + */ +private fun handleKakaoLoginResult( + token: OAuthToken?, + error: Throwable?, + loginMethod: String, + viewModel: LoginViewModel, + context: android.content.Context, +) { + when { + error != null -> { + if (error is ClientError && error.reason == ClientErrorCause.Cancelled) { + return + } + + // 카카오톡 앱 로그인 실패 시 카카오계정 웹으로 재시도 + if (loginMethod.contains("카카오톡 앱")) { + UserApiClient.instance.loginWithKakaoAccount(context) { retryToken, retryError -> + handleKakaoLoginResult(retryToken, retryError, "카카오계정 웹 (재시도)", viewModel, context) + } + } + } + + token != null -> { + // ViewModel에 토큰 전달 + viewModel.performKakaoLogin(token.accessToken) + } + } +} + @Composable fun LoginScreen( uiState: LoginUiState, diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt index 479dfc4f..eb16691d 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt @@ -45,6 +45,7 @@ class LoginViewModel viewModelScope.launch { try { updateLoadingState(isLoading = true) + val loginResult = authRepository.socialLogin(accessToken, providerType) updateLoadingState(isLoading = false) From e41051955aaca30a8258b85267d1a5759de39669 Mon Sep 17 00:00:00 2001 From: stopstone Date: Fri, 29 Aug 2025 17:51:25 +0900 Subject: [PATCH 024/100] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20datasource=20=EA=B3=84=EC=B8=B5?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=ED=99=95=EC=9E=A5=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=ED=95=9C=20=EC=BD=94=EB=93=9C=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카카오 SDK를 사용하여 카카오 로그인 기능 구현 - SocialLoginDataSource 인터페이스 및 KakaoDataSource 구현체 추가 - SocialLoginProcessor를 통해 소셜 로그인 방식 동적 선택 - AuthRepository에 performSocialLogin 메서드 추가하여 providerType에 따라 로그인 처리 분기 - LoginViewModel에서 performLogin 메서드를 통해 소셜 로그인 요청 - LoginScreen에 카카오 로그인 버튼 추가 및 로그인 로직 연동 --- .../alarmy/near/data/di/RepositoryModule.kt | 7 ++ .../near/data/repository/AuthRepository.kt | 8 ++ .../data/repository/AuthRepositoryImpl.kt | 27 ++++- .../near/data/source/KakaoDataSource.kt | 89 ++++++++++++++ .../near/data/source/SocialLoginDataSource.kt | 12 ++ .../near/data/source/SocialLoginProcessor.kt | 32 +++++ .../presentation/feature/login/LoginScreen.kt | 109 ++++++------------ .../feature/login/LoginViewModel.kt | 44 +------ 8 files changed, 214 insertions(+), 114 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/data/source/KakaoDataSource.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/data/source/SocialLoginDataSource.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/data/source/SocialLoginProcessor.kt diff --git a/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt b/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt index 5d73c37f..3fd4a0b6 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt @@ -6,10 +6,13 @@ import com.alarmy.near.data.repository.DefaultFriendRepository import com.alarmy.near.data.repository.ExampleRepository import com.alarmy.near.data.repository.ExampleRepositoryImpl import com.alarmy.near.data.repository.FriendRepository +import com.alarmy.near.data.source.KakaoDataSource +import com.alarmy.near.data.source.SocialLoginDataSource import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet import javax.inject.Singleton @Module @@ -26,4 +29,8 @@ interface RepositoryModule { @Binds @Singleton abstract fun bindAuthRepository(authRepositoryImpl: AuthRepositoryImpl): AuthRepository + + @Binds + @IntoSet + abstract fun bindKakaoDataSource(kakaoDataSource: KakaoDataSource): SocialLoginDataSource } diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt index 3a8d14fe..93fcdd55 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt @@ -7,6 +7,14 @@ import kotlinx.coroutines.flow.Flow interface AuthRepository { /** * 소셜 로그인 수행 + * Factory 패턴으로 ProviderType에 따라 자동 분기 + */ + suspend fun performSocialLogin( + providerType: ProviderType, + ): LoginResult + + /** + * 소셜 로그인 수행 (토큰 직접 전달) */ suspend fun socialLogin( accessToken: String, diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt index b406c013..90177c98 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt @@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import com.alarmy.near.data.source.SocialLoginProcessor import com.alarmy.near.model.LoginResult import com.alarmy.near.model.ProviderType import com.alarmy.near.network.request.SocialLoginRequest @@ -43,10 +44,34 @@ class AuthRepositoryImpl constructor( private val authService: AuthService, private val dataStore: DataStore, + private val socialLoginProcessor: SocialLoginProcessor, + @ApplicationContext private val context: Context, ) : AuthRepository { private val accessTokenKey = stringPreferencesKey("access_token") private val refreshTokenKey = stringPreferencesKey("refresh_token") + override suspend fun performSocialLogin(providerType: ProviderType): LoginResult = + try { + val result = socialLoginProcessor.processLogin(context, providerType) + + if (result.isSuccess) { + val accessToken = result.getOrThrow() + socialLogin(accessToken, providerType) + } else { + val providerName = providerType.name.lowercase() + LoginResult( + isSuccess = false, + errorMessage = result.exceptionOrNull()?.message ?: "$providerName 로그인에 실패했습니다", + ) + } + } catch (exception: Exception) { + val providerName = providerType.name.lowercase() + LoginResult( + isSuccess = false, + errorMessage = exception.message ?: "$providerName 로그인 중 오류가 발생했습니다", + ) + } + override suspend fun socialLogin( accessToken: String, providerType: ProviderType, @@ -87,7 +112,7 @@ class AuthRepositoryImpl ) } catch (exception: Exception) { val errorMessage = exception.message ?: "알 수 없는 오류가 발생했습니다" - + LoginResult( isSuccess = false, errorMessage = errorMessage, diff --git a/Near/app/src/main/java/com/alarmy/near/data/source/KakaoDataSource.kt b/Near/app/src/main/java/com/alarmy/near/data/source/KakaoDataSource.kt new file mode 100644 index 00000000..02567425 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/source/KakaoDataSource.kt @@ -0,0 +1,89 @@ +package com.alarmy.near.data.source + +import android.content.Context +import com.alarmy.near.model.ProviderType +import com.kakao.sdk.auth.model.OAuthToken +import com.kakao.sdk.common.model.ClientError +import com.kakao.sdk.common.model.ClientErrorCause +import com.kakao.sdk.user.UserApiClient +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.suspendCancellableCoroutine +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume + +/** + * 카카오 로그인 데이터 소스 + * 단일 책임: 카카오 SDK만 처리 + */ +@Singleton +class KakaoDataSource + @Inject + constructor( + @ApplicationContext private val context: Context, + ) : SocialLoginDataSource { + override val supportedType: ProviderType = ProviderType.KAKAO + + override suspend fun login(): Result = + try { + val token = + if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { + loginWithKakaoTalk(context) + } else { + loginWithKakaoAccount(context) + } + + if (token.isNotEmpty()) { + Result.success(token) + } else { + Result.failure(Exception("사용자가 로그인을 취소했습니다")) + } + } catch (exception: Exception) { + Result.failure(exception) + } + + private suspend fun loginWithKakaoTalk(context: Context): String = + suspendCancellableCoroutine { continuation -> + UserApiClient.instance.loginWithKakaoTalk(context) { token, error -> + when { + error != null -> { + if (error is ClientError && error.reason == ClientErrorCause.Cancelled) { + continuation.resume("") + } else { + UserApiClient.instance.loginWithKakaoAccount(context) { retryToken, retryError -> + handleLoginResult(retryToken, retryError, continuation) + } + } + } + token != null -> continuation.resume(token.accessToken) + else -> continuation.resume("") + } + } + } + + private suspend fun loginWithKakaoAccount(context: Context): String = + suspendCancellableCoroutine { continuation -> + UserApiClient.instance.loginWithKakaoAccount(context) { token, error -> + handleLoginResult(token, error, continuation) + } + } + + private fun handleLoginResult( + token: OAuthToken?, + error: Throwable?, + continuation: CancellableContinuation, + ) { + when { + error != null -> { + if (error is ClientError && error.reason == ClientErrorCause.Cancelled) { + continuation.resume("") + } else { + continuation.resumeWith(Result.failure(error)) + } + } + token != null -> continuation.resume(token.accessToken) + else -> continuation.resume("") + } + } + } diff --git a/Near/app/src/main/java/com/alarmy/near/data/source/SocialLoginDataSource.kt b/Near/app/src/main/java/com/alarmy/near/data/source/SocialLoginDataSource.kt new file mode 100644 index 00000000..e00b9001 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/source/SocialLoginDataSource.kt @@ -0,0 +1,12 @@ +package com.alarmy.near.data.source + +import com.alarmy.near.model.ProviderType + +/** + * 소셜 로그인 데이터 소스 인터페이스 + */ +interface SocialLoginDataSource { + val supportedType: ProviderType + + suspend fun login(): Result +} diff --git a/Near/app/src/main/java/com/alarmy/near/data/source/SocialLoginProcessor.kt b/Near/app/src/main/java/com/alarmy/near/data/source/SocialLoginProcessor.kt new file mode 100644 index 00000000..14c640e9 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/source/SocialLoginProcessor.kt @@ -0,0 +1,32 @@ +package com.alarmy.near.data.source + +import android.content.Context +import com.alarmy.near.model.ProviderType +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 소셜 로그인 프로세서 + * Strategy 패턴으로 동적으로 로그인 방식 선택 + */ +@Singleton +class SocialLoginProcessor @Inject constructor( + private val socialLoginDataSources: Set<@JvmSuppressWildcards SocialLoginDataSource> +) { + + /** + * 소셜 로그인 처리 + * @param context Android Context + * @param providerType 로그인 제공자 타입 + * @return 로그인 결과 + */ + suspend fun processLogin( + context: Context, + providerType: ProviderType + ): Result { + val dataSource = socialLoginDataSources.find { it.supportedType == providerType } + ?: return Result.failure(Exception("지원하지 않는 로그인 타입입니다: ${providerType.name}")) + + return dataSource.login(context) + } +} 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 31cace11..3a4691f6 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 @@ -19,7 +19,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -27,18 +26,14 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R +import com.alarmy.near.model.ProviderType import com.alarmy.near.presentation.ui.theme.NearTheme -import com.kakao.sdk.auth.model.OAuthToken -import com.kakao.sdk.common.model.ClientError -import com.kakao.sdk.common.model.ClientErrorCause -import com.kakao.sdk.user.UserApiClient @Composable internal fun LoginRoute( onNavigateToHome: () -> Unit, viewModel: LoginViewModel = hiltViewModel(), ) { - val context = LocalContext.current val uiState by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { @@ -47,67 +42,18 @@ internal fun LoginRoute( } } - /** - * 카카오 로그인 처리 함수 - */ - fun handleKakaoLogin() { - // 카카오톡으로 로그인 가능 여부 확인 - val isKakaoTalkAvailable = UserApiClient.instance.isKakaoTalkLoginAvailable(context) - - if (isKakaoTalkAvailable) { - // 카카오톡 앱이 설치되어 있으면 카카오톡으로 로그인 - UserApiClient.instance.loginWithKakaoTalk(context) { token, error -> - handleKakaoLoginResult(token, error, "카카오톡 앱", viewModel, context) - } - } else { - // 카카오톡 앱이 없으면 카카오계정으로 웹 로그인 - UserApiClient.instance.loginWithKakaoAccount(context) { token, error -> - handleKakaoLoginResult(token, error, "카카오계정 웹", viewModel, context) - } - } - } - LoginScreen( uiState = uiState, - performKakaoLogin = ::handleKakaoLogin, + onLoginClick = { providerType -> + viewModel.performLogin(providerType) + }, ) } -/** - * 카카오 로그인 결과 처리 - */ -private fun handleKakaoLoginResult( - token: OAuthToken?, - error: Throwable?, - loginMethod: String, - viewModel: LoginViewModel, - context: android.content.Context, -) { - when { - error != null -> { - if (error is ClientError && error.reason == ClientErrorCause.Cancelled) { - return - } - - // 카카오톡 앱 로그인 실패 시 카카오계정 웹으로 재시도 - if (loginMethod.contains("카카오톡 앱")) { - UserApiClient.instance.loginWithKakaoAccount(context) { retryToken, retryError -> - handleKakaoLoginResult(retryToken, retryError, "카카오계정 웹 (재시도)", viewModel, context) - } - } - } - - token != null -> { - // ViewModel에 토큰 전달 - viewModel.performKakaoLogin(token.accessToken) - } - } -} - @Composable fun LoginScreen( uiState: LoginUiState, - performKakaoLogin: () -> Unit, + onLoginClick: (ProviderType) -> Unit, ) { Column( modifier = @@ -119,9 +65,10 @@ fun LoginScreen( Spacer(modifier = Modifier.weight(1f)) - KakaoLoginButton( - enable = uiState.isLoading, - onLoginClick = performKakaoLogin, + // 소셜 로그인 버튼 + SocialLoginButtons( + isLoading = uiState.isLoading, + onLoginClick = onLoginClick, ) } } @@ -155,9 +102,29 @@ private fun LoginIntroductionSection(modifier: Modifier = Modifier) { } @Composable -private fun ColumnScope.KakaoLoginButton( - enable: Boolean, - onLoginClick: () -> Unit, +private fun ColumnScope.SocialLoginButtons( + isLoading: Boolean, + onLoginClick: (ProviderType) -> Unit, +) { + // 카카오 로그인 버튼 + SocialLoginButton( + isEnabled = !isLoading, + providerType = ProviderType.KAKAO, + buttonResource = R.drawable.btn_kakao_login, + contentDescription = stringResource(R.string.login_kakao_login_button_text), + onLoginClick = onLoginClick, + ) + + Spacer(modifier = Modifier.size(LoginScreenConstants.BOTTOM_SPACING.dp)) +} + +@Composable +private fun ColumnScope.SocialLoginButton( + isEnabled: Boolean, + providerType: ProviderType, + buttonResource: Int, + contentDescription: String, + onLoginClick: (ProviderType) -> Unit, ) { Image( modifier = @@ -167,15 +134,13 @@ private fun ColumnScope.KakaoLoginButton( .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, - enabled = !enable, + enabled = isEnabled, ) { - onLoginClick() + onLoginClick(providerType) }, - painter = painterResource(R.drawable.btn_kakao_login), - contentDescription = stringResource(R.string.login_kakao_login_button_text), + painter = painterResource(buttonResource), + contentDescription = contentDescription, ) - - Spacer(modifier = Modifier.size(LoginScreenConstants.BOTTOM_SPACING.dp)) } private object LoginScreenConstants { @@ -191,7 +156,7 @@ fun LoginScreenPreview() { NearTheme { LoginScreen( uiState = LoginUiState(), - performKakaoLogin = {}, + onLoginClick = { }, ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt index eb16691d..1497ed8b 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt @@ -1,6 +1,5 @@ package com.alarmy.near.presentation.feature.login -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.alarmy.near.data.repository.AuthRepository @@ -34,19 +33,13 @@ class LoginViewModel /** * 소셜 로그인 수행 - * - * @param accessToken 소셜 플랫폼에서 받은 Access Token - * @param providerType 소셜 로그인 제공자 타입 */ - fun performSocialLogin( - accessToken: String, - providerType: com.alarmy.near.model.ProviderType, - ) { + fun performLogin(providerType: ProviderType) { viewModelScope.launch { try { updateLoadingState(isLoading = true) - - val loginResult = authRepository.socialLogin(accessToken, providerType) + + val loginResult = authRepository.performSocialLogin(providerType) updateLoadingState(isLoading = false) @@ -63,43 +56,12 @@ class LoginViewModel } } - /** - * 카카오 토큰으로 로그인 수행 - * UI에서 카카오 로그인을 완료한 후 토큰을 받아서 처리 - */ - fun performKakaoLogin(kakaoAccessToken: String) { - performSocialLogin(kakaoAccessToken, ProviderType.KAKAO) - } - - /** - * 로그인 상태 확인 - */ - fun checkLoginStatus() { - viewModelScope.launch { - try { - val isLoggedIn = authRepository.isLoggedIn() - if (isLoggedIn) { - _loginSuccessEvent.send(Unit) - } - } catch (exception: Exception) { - _errorEvent.send(exception) - } - } - } - /** * 로딩 상태 업데이트 */ private fun updateLoadingState(isLoading: Boolean) { _uiState.value = _uiState.value.copy(isLoading = isLoading) } - - /** - * 에러 상태 초기화 - */ - fun clearError() { - _uiState.value = _uiState.value.copy(hasError = false) - } } /** From 88361621a1a9b5f267d34c34afa74473f2d98506 Mon Sep 17 00:00:00 2001 From: stopstone Date: Fri, 29 Aug 2025 19:14:06 +0900 Subject: [PATCH 025/100] =?UTF-8?q?refactor:=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=86=8C=EC=8A=A4=20=ED=8C=8C=EC=9D=BC=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/datasource/SocialLoginDataSource.kt | 20 ++++++++++++ .../data/datasource/SocialLoginProcessor.kt | 32 +++++++++++++++++++ .../near/data/source/SocialLoginDataSource.kt | 12 ------- .../near/data/source/SocialLoginProcessor.kt | 32 ------------------- 4 files changed, 52 insertions(+), 44 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginDataSource.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginProcessor.kt delete mode 100644 Near/app/src/main/java/com/alarmy/near/data/source/SocialLoginDataSource.kt delete mode 100644 Near/app/src/main/java/com/alarmy/near/data/source/SocialLoginProcessor.kt diff --git a/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginDataSource.kt b/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginDataSource.kt new file mode 100644 index 00000000..9d2bb534 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginDataSource.kt @@ -0,0 +1,20 @@ +package com.alarmy.near.data.datasource + +import com.alarmy.near.model.ProviderType + +/** + * 소셜 로그인 데이터 소스 인터페이스 + * Strategy 패턴으로 각 소셜 플랫폼별로 구현 + */ +interface SocialLoginDataSource { + /** + * 지원하는 소셜 로그인 타입 + */ + val supportedType: ProviderType + + /** + * 소셜 로그인 수행 + * Context는 생성자에서 주입받아 사용 + */ + suspend fun login(): Result +} diff --git a/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginProcessor.kt b/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginProcessor.kt new file mode 100644 index 00000000..1944d12a --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginProcessor.kt @@ -0,0 +1,32 @@ +package com.alarmy.near.data.datasource + + +import com.alarmy.near.model.ProviderType +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 소셜 로그인 프로세서 + * Strategy 패턴으로 동적으로 로그인 방식 선택 + */ +@Singleton +class SocialLoginProcessor + @Inject + constructor( + private val socialLoginDataSources: Set<@JvmSuppressWildcards SocialLoginDataSource>, + ) { + /** + * 소셜 로그인 처리 + * @param providerType 로그인 제공자 타입 + * @return 로그인 결과 + */ + suspend fun processLogin( + providerType: ProviderType, + ): Result { + val dataSource = + socialLoginDataSources.find { it.supportedType == providerType } + ?: return Result.failure(Exception("지원하지 않는 로그인 타입입니다: ${providerType.name}")) + + return dataSource.login() + } + } diff --git a/Near/app/src/main/java/com/alarmy/near/data/source/SocialLoginDataSource.kt b/Near/app/src/main/java/com/alarmy/near/data/source/SocialLoginDataSource.kt deleted file mode 100644 index e00b9001..00000000 --- a/Near/app/src/main/java/com/alarmy/near/data/source/SocialLoginDataSource.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.alarmy.near.data.source - -import com.alarmy.near.model.ProviderType - -/** - * 소셜 로그인 데이터 소스 인터페이스 - */ -interface SocialLoginDataSource { - val supportedType: ProviderType - - suspend fun login(): Result -} diff --git a/Near/app/src/main/java/com/alarmy/near/data/source/SocialLoginProcessor.kt b/Near/app/src/main/java/com/alarmy/near/data/source/SocialLoginProcessor.kt deleted file mode 100644 index 14c640e9..00000000 --- a/Near/app/src/main/java/com/alarmy/near/data/source/SocialLoginProcessor.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.alarmy.near.data.source - -import android.content.Context -import com.alarmy.near.model.ProviderType -import javax.inject.Inject -import javax.inject.Singleton - -/** - * 소셜 로그인 프로세서 - * Strategy 패턴으로 동적으로 로그인 방식 선택 - */ -@Singleton -class SocialLoginProcessor @Inject constructor( - private val socialLoginDataSources: Set<@JvmSuppressWildcards SocialLoginDataSource> -) { - - /** - * 소셜 로그인 처리 - * @param context Android Context - * @param providerType 로그인 제공자 타입 - * @return 로그인 결과 - */ - suspend fun processLogin( - context: Context, - providerType: ProviderType - ): Result { - val dataSource = socialLoginDataSources.find { it.supportedType == providerType } - ?: return Result.failure(Exception("지원하지 않는 로그인 타입입니다: ${providerType.name}")) - - return dataSource.login(context) - } -} From 78237cad25ea77e93cc312c4f53e02d233ad029c Mon Sep 17 00:00:00 2001 From: stopstone Date: Fri, 29 Aug 2025 19:17:16 +0900 Subject: [PATCH 026/100] =?UTF-8?q?Refactor:=20DataStoreModule=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataStoreModule을 AuthRepositoryImpl에서 별도의 파일로 분리했습니다. --- .../{source => datasource}/KakaoDataSource.kt | 2 +- .../alarmy/near/data/di/DataStoreModule.kt | 25 +++++++++++++++++++ .../data/repository/AuthRepositoryImpl.kt | 17 ------------- 3 files changed, 26 insertions(+), 18 deletions(-) rename Near/app/src/main/java/com/alarmy/near/data/{source => datasource}/KakaoDataSource.kt (98%) create mode 100644 Near/app/src/main/java/com/alarmy/near/data/di/DataStoreModule.kt diff --git a/Near/app/src/main/java/com/alarmy/near/data/source/KakaoDataSource.kt b/Near/app/src/main/java/com/alarmy/near/data/datasource/KakaoDataSource.kt similarity index 98% rename from Near/app/src/main/java/com/alarmy/near/data/source/KakaoDataSource.kt rename to Near/app/src/main/java/com/alarmy/near/data/datasource/KakaoDataSource.kt index 02567425..46b5c95c 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/source/KakaoDataSource.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/datasource/KakaoDataSource.kt @@ -1,4 +1,4 @@ -package com.alarmy.near.data.source +package com.alarmy.near.data.datasource import android.content.Context import com.alarmy.near.model.ProviderType diff --git a/Near/app/src/main/java/com/alarmy/near/data/di/DataStoreModule.kt b/Near/app/src/main/java/com/alarmy/near/data/di/DataStoreModule.kt new file mode 100644 index 00000000..5f73bfb6 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/di/DataStoreModule.kt @@ -0,0 +1,25 @@ +package com.alarmy.near.data.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +// DataStore 확장 프로퍼티 +private val Context.dataStore: DataStore by preferencesDataStore(name = "auth_preferences") + +@Module +@InstallIn(SingletonComponent::class) +object DataStoreModule { + @Provides + @Singleton + fun provideDataStore( + @ApplicationContext context: Context, + ): DataStore = context.dataStore +} diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt index 90177c98..e510ae04 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt @@ -21,23 +21,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import retrofit2.HttpException import javax.inject.Inject -import javax.inject.Singleton - -// DataStore 확장 프로퍼티 -private val Context.dataStore: DataStore by preferencesDataStore(name = "auth_preferences") - -/** - * DataStore 제공 모듈 - */ -@Module -@InstallIn(SingletonComponent::class) -object DataStoreModule { - @Provides - @Singleton - fun provideDataStore( - @ApplicationContext context: Context, - ): DataStore = context.dataStore -} class AuthRepositoryImpl @Inject From fb89c57a79c87593be60989f40f71ef0f06b734a Mon Sep 17 00:00:00 2001 From: stopstone Date: Fri, 29 Aug 2025 19:18:19 +0900 Subject: [PATCH 027/100] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EC=9D=B8=EC=A6=9D=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DataStore를 사용한 토큰 저장 및 관리 클래스 (`TokenPreferences`) 추가 - 토큰 갱신 요청 및 응답 모델 (`TokenRefreshRequest`, `TokenRefreshResponse`) 추가 - `AuthRepository` 및 `AuthRepositoryImpl`에 토큰 갱신 로직 추가 - `TokenInterceptor`에서 Authorization 헤더를 동적으로 추가하고, 인증이 필요 없는 요청은 제외하도록 수정 - `LoginResponse` 모델에서 `refreshTokenInfo`를 `refreshToken`으로 변경 - `AuthService`에 토큰 갱신 API (`/auth/renew`) 인터페이스 추가 --- .../alarmy/near/data/di/RepositoryModule.kt | 4 +- .../data/local/datastore/TokenPreferences.kt | 98 +++++++++++++++++++ .../near/data/repository/AuthRepository.kt | 34 ++----- .../data/repository/AuthRepositoryImpl.kt | 86 +++++++--------- .../near/network/auth/TokenInterceptor.kt | 59 +++++++++-- .../network/request/TokenRefreshRequest.kt | 11 +++ .../near/network/response/LoginResponse.kt | 13 +-- .../network/response/TokenRefreshResponse.kt | 12 +++ .../near/network/service/AuthService.kt | 8 ++ 9 files changed, 229 insertions(+), 96 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/network/request/TokenRefreshRequest.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/network/response/TokenRefreshResponse.kt diff --git a/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt b/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt index 3fd4a0b6..704b2c3d 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt @@ -1,13 +1,13 @@ package com.alarmy.near.data.di +import com.alarmy.near.data.datasource.KakaoDataSource +import com.alarmy.near.data.datasource.SocialLoginDataSource import com.alarmy.near.data.repository.AuthRepository import com.alarmy.near.data.repository.AuthRepositoryImpl import com.alarmy.near.data.repository.DefaultFriendRepository import com.alarmy.near.data.repository.ExampleRepository import com.alarmy.near.data.repository.ExampleRepositoryImpl import com.alarmy.near.data.repository.FriendRepository -import com.alarmy.near.data.source.KakaoDataSource -import com.alarmy.near.data.source.SocialLoginDataSource import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt b/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt new file mode 100644 index 00000000..a0fdc6ac --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt @@ -0,0 +1,98 @@ +package com.alarmy.near.data.local.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 토큰 저장소 DataStore 관리 클래스 + * 액세스 토큰과 리프레시 토큰의 저장, 조회, 삭제를 담당 + */ +@Singleton +class TokenPreferences + @Inject + constructor( + private val dataStore: DataStore, + ) { + private val accessTokenKey = stringPreferencesKey("access_token") + private val refreshTokenKey = stringPreferencesKey("refresh_token") + + + + /** + * 액세스 토큰 저장 + */ + suspend fun saveAccessToken(token: String) { + dataStore.edit { preferences -> + preferences[accessTokenKey] = token + } + } + + /** + * 리프레시 토큰 저장 + */ + suspend fun saveRefreshToken(token: String) { + dataStore.edit { preferences -> + preferences[refreshTokenKey] = token + } + } + + /** + * 두 토큰 동시 저장 + */ + suspend fun saveTokens( + accessToken: String, + refreshToken: String?, + ) { + dataStore.edit { preferences -> + preferences[accessTokenKey] = accessToken + refreshToken?.let { + preferences[refreshTokenKey] = it + } + } + } + + /** + * 액세스 토큰 조회 + */ + suspend fun getAccessToken(): String? = dataStore.data.first()[accessTokenKey] + + /** + * 리프레시 토큰 조회 + */ + suspend fun getRefreshToken(): String? = dataStore.data.first()[refreshTokenKey] + + /** + * 토큰 존재 여부 확인 + */ + suspend fun hasValidTokens(): Boolean { + val accessToken = getAccessToken() + return !accessToken.isNullOrBlank() + } + + /** + * 모든 토큰 삭제 + */ + suspend fun clearAllTokens() { + dataStore.edit { preferences -> + preferences.remove(accessTokenKey) + preferences.remove(refreshTokenKey) + } + } + + + + /** + * 로그인 상태 관찰 + */ + fun observeLoginStatus() = + dataStore.data.map { preferences -> + !preferences[accessTokenKey].isNullOrBlank() + } + } diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt index 93fcdd55..0290d973 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt @@ -5,14 +5,9 @@ import com.alarmy.near.model.ProviderType import kotlinx.coroutines.flow.Flow interface AuthRepository { - /** - * 소셜 로그인 수행 - * Factory 패턴으로 ProviderType에 따라 자동 분기 - */ - suspend fun performSocialLogin( - providerType: ProviderType, - ): LoginResult - + // 소셜 로그인 수행 + suspend fun performSocialLogin(providerType: ProviderType): LoginResult + /** * 소셜 로그인 수행 (토큰 직접 전달) */ @@ -21,27 +16,18 @@ interface AuthRepository { providerType: ProviderType, ): LoginResult - /** - * 로그아웃 수행 - * 로컬에 저장된 토큰을 삭제 - */ + // 로그아웃 수행 suspend fun logout() - /** - * 로그인 상태 확인 - * 저장된 토큰이 있는지 확인 - */ + // 로그인 상태 확인 suspend fun isLoggedIn(): Boolean - /** - * 현재 사용자 토큰 가져오기 - * 저장된 Access Token 반환 - */ + // 현재 사용자 토큰 가져오기 suspend fun getCurrentUserToken(): String? - /** - * 로그인 상태 관찰 - * 토큰 변화를 실시간으로 관찰 - */ + // 로그인 상태 확인 fun observeLoginStatus(): Flow + + // 토큰 갱신 + suspend fun refreshToken(): Boolean } diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt index e510ae04..a2d568bc 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt @@ -1,24 +1,13 @@ package com.alarmy.near.data.repository -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import com.alarmy.near.data.source.SocialLoginProcessor +import com.alarmy.near.data.datasource.SocialLoginProcessor +import com.alarmy.near.data.local.datastore.TokenPreferences import com.alarmy.near.model.LoginResult import com.alarmy.near.model.ProviderType import com.alarmy.near.network.request.SocialLoginRequest +import com.alarmy.near.network.request.TokenRefreshRequest import com.alarmy.near.network.service.AuthService -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map import retrofit2.HttpException import javax.inject.Inject @@ -26,16 +15,12 @@ class AuthRepositoryImpl @Inject constructor( private val authService: AuthService, - private val dataStore: DataStore, private val socialLoginProcessor: SocialLoginProcessor, - @ApplicationContext private val context: Context, + private val tokenPreferences: TokenPreferences, ) : AuthRepository { - private val accessTokenKey = stringPreferencesKey("access_token") - private val refreshTokenKey = stringPreferencesKey("refresh_token") - override suspend fun performSocialLogin(providerType: ProviderType): LoginResult = try { - val result = socialLoginProcessor.processLogin(context, providerType) + val result = socialLoginProcessor.processLogin(providerType) if (result.isSuccess) { val accessToken = result.getOrThrow() @@ -69,15 +54,15 @@ class AuthRepositoryImpl val response = authService.socialLogin(request) // 토큰 저장 - saveTokens( + tokenPreferences.saveTokens( accessToken = response.accessToken, - refreshToken = response.refreshTokenInfo?.token, + refreshToken = response.refreshToken, ) LoginResult( isSuccess = true, accessToken = response.accessToken, - refreshToken = response.refreshTokenInfo?.token, + refreshToken = response.refreshToken, ) } catch (exception: HttpException) { val errorMessage = @@ -104,7 +89,7 @@ class AuthRepositoryImpl override suspend fun logout() { try { - clearTokens() + tokenPreferences.clearAllTokens() } catch (exception: Exception) { throw exception } @@ -112,42 +97,45 @@ class AuthRepositoryImpl override suspend fun isLoggedIn(): Boolean = try { - val token = getCurrentUserToken() - val isLoggedIn = !token.isNullOrBlank() - isLoggedIn + tokenPreferences.hasValidTokens() } catch (exception: Exception) { false } override suspend fun getCurrentUserToken(): String? = try { - val token = dataStore.data.first()[accessTokenKey] - token + tokenPreferences.getAccessToken() } catch (exception: Exception) { null } - override fun observeLoginStatus(): Flow = - dataStore.data.map { preferences -> - !preferences[accessTokenKey].isNullOrBlank() - } + override fun observeLoginStatus(): Flow = tokenPreferences.observeLoginStatus() - private suspend fun saveTokens( - accessToken: String, - refreshToken: String?, - ) { - dataStore.edit { preferences -> - preferences[accessTokenKey] = accessToken - refreshToken?.let { - preferences[refreshTokenKey] = it - } - } - } + override suspend fun refreshToken(): Boolean = + try { + val refreshToken = tokenPreferences.getRefreshToken() ?: return false + + // 토큰 갱신 API 호출 + val request = TokenRefreshRequest(refreshToken = refreshToken) + val response = authService.renewToken(request) - private suspend fun clearTokens() { - dataStore.edit { preferences -> - preferences.remove(accessTokenKey) - preferences.remove(refreshTokenKey) + // 새로운 토큰 저장 + tokenPreferences.saveTokens( + accessToken = response.accessToken, + refreshToken = response.refreshToken, + ) + + true + } catch (exception: Exception) { + // 토큰 갱신 실패 시 처리 + when (exception) { + is HttpException -> { + if (exception.code() == 401 || exception.code() == 403) { + // 리프레시 토큰도 만료된 경우 모든 토큰 삭제 + tokenPreferences.clearAllTokens() + } + } + } + false } - } } diff --git a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt index e2e99566..0ac0a574 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt @@ -1,21 +1,60 @@ package com.alarmy.near.network.auth -import com.alarmy.near.BuildConfig +import com.alarmy.near.data.local.datastore.TokenPreferences +import kotlinx.coroutines.runBlocking import okhttp3.Interceptor +import okhttp3.Request import okhttp3.Response import javax.inject.Inject +import javax.inject.Singleton +/** + * 토큰 인터셉터 + * Authorization 헤더를 자동으로 추가하고 인증이 필요없는 요청은 제외 + */ +@Singleton class TokenInterceptor @Inject - constructor() : Interceptor { + constructor( + private val tokenPreferences: TokenPreferences, + ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { - val request = chain.request() - return chain.proceed( - request = - request - .newBuilder() - .addHeader("Authorization", "Bearer ${BuildConfig.TEMP_TOKEN}") - .build(), - ) + val originalRequest = chain.request() + + if (isAuthExcludedRequest(originalRequest)) { + return chain.proceed(originalRequest) + } + + val requestWithAuth = + runBlocking { + addAuthHeader(originalRequest) + } + + return chain.proceed(requestWithAuth) + } + + /** + * 인증이 필요없는 요청인지 확인 + */ + private fun isAuthExcludedRequest(request: Request): Boolean { + val url = request.url.toString() + return url.contains("/auth/social") || + url.contains("/auth/renew") + } + + /** + * Authorization 헤더 추가 + */ + private suspend fun addAuthHeader(originalRequest: Request): Request { + val accessToken = tokenPreferences.getAccessToken() + + return if (accessToken != null) { + originalRequest + .newBuilder() + .header("Authorization", "Bearer $accessToken") + .build() + } else { + originalRequest + } } } diff --git a/Near/app/src/main/java/com/alarmy/near/network/request/TokenRefreshRequest.kt b/Near/app/src/main/java/com/alarmy/near/network/request/TokenRefreshRequest.kt new file mode 100644 index 00000000..2edc96ef --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/request/TokenRefreshRequest.kt @@ -0,0 +1,11 @@ +package com.alarmy.near.network.request + +import kotlinx.serialization.Serializable + +/** + * 토큰 갱신 요청 모델 + */ +@Serializable +data class TokenRefreshRequest( + val refreshToken: String +) diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/LoginResponse.kt b/Near/app/src/main/java/com/alarmy/near/network/response/LoginResponse.kt index 9cdb1622..3b77dfdc 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/response/LoginResponse.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/response/LoginResponse.kt @@ -8,15 +8,6 @@ import kotlinx.serialization.Serializable data class LoginResponse( @SerialName("accessToken") val accessToken: String, - @SerialName("refreshTokenInfo") - val refreshTokenInfo: RefreshTokenInfo? = null, -) - -// 리프레시 토큰 정보 -@Serializable -data class RefreshTokenInfo( - @SerialName("token") - val token: String, - @SerialName("expiresAt") - val expiresAt: String, + @SerialName("refreshToken") + val refreshToken: String? = null, ) diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/TokenRefreshResponse.kt b/Near/app/src/main/java/com/alarmy/near/network/response/TokenRefreshResponse.kt new file mode 100644 index 00000000..04806840 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/response/TokenRefreshResponse.kt @@ -0,0 +1,12 @@ +package com.alarmy.near.network.response + +import kotlinx.serialization.Serializable + +/** + * 토큰 갱신 응답 모델 + */ +@Serializable +data class TokenRefreshResponse( + val accessToken: String, + val refreshToken: String? = null +) diff --git a/Near/app/src/main/java/com/alarmy/near/network/service/AuthService.kt b/Near/app/src/main/java/com/alarmy/near/network/service/AuthService.kt index 646adb59..587008ea 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/service/AuthService.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/service/AuthService.kt @@ -1,7 +1,9 @@ package com.alarmy.near.network.service import com.alarmy.near.network.request.SocialLoginRequest +import com.alarmy.near.network.request.TokenRefreshRequest import com.alarmy.near.network.response.LoginResponse +import com.alarmy.near.network.response.TokenRefreshResponse import retrofit2.http.Body import retrofit2.http.POST @@ -14,4 +16,10 @@ interface AuthService { suspend fun socialLogin( @Body request: SocialLoginRequest, ): LoginResponse + + // 토큰 갱신 API 호출 + @POST("/auth/renew") + suspend fun renewToken( + @Body request: TokenRefreshRequest, + ): TokenRefreshResponse } From 431ccc2b4b718f7d6283111f9cae45745eabc00e Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sat, 30 Aug 2025 17:03:46 +0900 Subject: [PATCH 028/100] =?UTF-8?q?feat:=20parcelize=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Near/app/build.gradle.kts | 1 + Near/gradle/libs.versions.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/Near/app/build.gradle.kts b/Near/app/build.gradle.kts index bd42f1a5..926563d5 100644 --- a/Near/app/build.gradle.kts +++ b/Near/app/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.hilt.application) alias(libs.plugins.kotlin.kapt) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotlin.parcelize) } android { diff --git a/Near/gradle/libs.versions.toml b/Near/gradle/libs.versions.toml index 9c13f8fc..93bd4ca3 100644 --- a/Near/gradle/libs.versions.toml +++ b/Near/gradle/libs.versions.toml @@ -64,4 +64,5 @@ hilt-application = { id = "com.google.dagger.hilt.android", version.ref = "hiltV kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintVersion" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-parcelize = {id = "kotlin-parcelize"} From 04d6d3628e95a26a14040c9b8940853b2175a7ec Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sat, 30 Aug 2025 17:05:51 +0900 Subject: [PATCH 029/100] =?UTF-8?q?feat:=20Route=20=EC=9D=B8=EC=9E=90=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=EC=9D=84=20=EC=9C=84=ED=95=9C=20serializable?= =?UTF-8?q?,=20pacelable=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src/main/java/com/alarmy/near/model/Friend.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt index 1acfe62a..14c63a49 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt @@ -1,10 +1,13 @@ package com.alarmy.near.model +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.Locale +@Parcelize @Serializable data class Friend( val friendId: String, @@ -17,7 +20,7 @@ data class Friend( val memo: String?, val phone: String?, val lastContactAt: String?, // "2025-07-16" -) { +) : Parcelable { val isContactedToday: Boolean get() = lastContactAt?.isToday() ?: false @@ -30,14 +33,16 @@ data class Friend( } @Serializable +@Parcelize data class ContactFrequency( val reminderInterval: ReminderInterval, val dayOfWeek: String, -) +) : Parcelable @Serializable +@Parcelize data class Anniversary( val id: Int, val title: String, val date: String, -) +) : Parcelable From c25a8eb144f2ab187fcfc436bdd5a5faf677df00 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sat, 30 Aug 2025 17:09:00 +0900 Subject: [PATCH 030/100] =?UTF-8?q?feat:=20friend=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EC=A0=84=EB=8B=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FriendProfileEditorScreen.kt | 54 +++++- .../FriendProfileEditorViewModel.kt | 154 +++++++++++++++++- .../navigation/FriendProfileNavigation.kt | 64 +++++++- .../uistate/FriendProfileEditorUIState.kt | 42 +++++ .../presentation/feature/main/NearNavHost.kt | 4 +- 5 files changed, 299 insertions(+), 19 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index fab2cd0f..91e7f658 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -36,9 +36,15 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R +import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.Relation +import com.alarmy.near.model.ReminderInterval import com.alarmy.near.presentation.feature.friendprofileedittor.component.NearDatePicker import com.alarmy.near.presentation.feature.friendprofileedittor.component.ReminderIntervalBottomSheet +import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIState import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar import com.alarmy.near.presentation.ui.component.radiobutton.NearSmallRadioButton import com.alarmy.near.presentation.ui.component.textfield.NearLimitedTextField @@ -48,11 +54,16 @@ import com.alarmy.near.presentation.ui.theme.NearTheme @Composable fun FriendProfileEditorRoute( + viewModel: FriendProfileEditorViewModel = hiltViewModel(), onShowErrorSnackBar: (throwable: Throwable?) -> Unit, onClickBackButton: () -> Unit = {}, ) { + val friendProfileEditorUIState = viewModel.uiState.collectAsStateWithLifecycle() FriendProfileEditorScreen( onClickBackButton = onClickBackButton, + onNameChanged = viewModel::onNameChanged, + friendProfileEditorUIState = friendProfileEditorUIState.value, + onRelationChanged = viewModel::onRelationChanged, ) } @@ -60,7 +71,10 @@ fun FriendProfileEditorRoute( @Composable fun FriendProfileEditorScreen( modifier: Modifier = Modifier, + friendProfileEditorUIState: FriendProfileEditorUIState, onClickBackButton: () -> Unit = {}, + onNameChanged: (String) -> Unit = {}, + onRelationChanged: (Relation) -> Unit = {}, ) { val isErrorMessageVisible = remember { mutableStateOf(false) } val density = LocalDensity.current @@ -123,8 +137,9 @@ fun FriendProfileEditorScreen( Spacer(modifier = Modifier.width(55.dp)) NearTextField( modifier = Modifier.weight(1f), - value = "가나다", + value = friendProfileEditorUIState.name.value, onValueChange = { + onNameChanged(it) }, ) } @@ -156,9 +171,15 @@ fun FriendProfileEditorScreen( .padding(end = 35.dp), horizontalArrangement = Arrangement.SpaceBetween, ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = + Modifier.onNoRippleClick(onClick = { + onRelationChanged(Relation.FRIEND) + }), + verticalAlignment = Alignment.CenterVertically, + ) { NearSmallRadioButton( - selected = false, + selected = friendProfileEditorUIState.relation == Relation.FRIEND, onClick = {}, ) Spacer(modifier = Modifier.width(8.dp)) @@ -168,9 +189,15 @@ fun FriendProfileEditorScreen( color = NearTheme.colors.BLACK_1A1A1A, ) } - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = + Modifier.onNoRippleClick(onClick = { + onRelationChanged(Relation.FAMILY) + }), + verticalAlignment = Alignment.CenterVertically, + ) { NearSmallRadioButton( - selected = false, + selected = friendProfileEditorUIState.relation == Relation.FAMILY, onClick = {}, ) Spacer(modifier = Modifier.width(8.dp)) @@ -182,8 +209,8 @@ fun FriendProfileEditorScreen( } Row(verticalAlignment = Alignment.CenterVertically) { NearSmallRadioButton( - selected = false, - onClick = {}, + selected = friendProfileEditorUIState.relation == Relation.ACQUAINTANCE, + onClick = { onRelationChanged(Relation.ACQUAINTANCE) }, ) Spacer(modifier = Modifier.width(8.dp)) Text( @@ -234,7 +261,7 @@ fun FriendProfileEditorScreen( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - "2주 (수요일 마다)", + text = stringResource(friendProfileEditorUIState.contactFrequency.reminderInterval.labelRes) + "()", style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLACK_1A1A1A, ) @@ -469,6 +496,15 @@ fun FriendProfileEditorScreen( @Composable fun FriendProfileEditorScreenPreview() { NearTheme { - FriendProfileEditorScreen() + FriendProfileEditorScreen( + friendProfileEditorUIState = + FriendProfileEditorUIState( + contactFrequency = + ContactFrequency( + reminderInterval = ReminderInterval.EVERY_DAY, + dayOfWeek = "2025-01-01", + ), + ), + ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt index d3cbb6ce..0e50341f 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt @@ -1,21 +1,165 @@ package com.alarmy.near.presentation.feature.friendprofileedittor +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.navigation.toRoute +import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.Friend import com.alarmy.near.model.Relation +import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.RouteFriendProfileEditor +import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.AnniversaryUIState +import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIState +import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.toUiModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import java.time.LocalDate import javax.inject.Inject @HiltViewModel class FriendProfileEditorViewModel @Inject - constructor() : ViewModel() { - private val _relation: MutableStateFlow = MutableStateFlow(null) - val relation = _relation.asStateFlow() + constructor( + savedStateHandle: SavedStateHandle, + ) : ViewModel() { + private val routeFriendProfileEditor: RouteFriendProfileEditor = + savedStateHandle.toRoute(RouteFriendProfileEditor.routeTypeMap) + private val friend: Friend = routeFriendProfileEditor.friend + private val _uiState: MutableStateFlow = + MutableStateFlow(friend.toUiModel()) + val uiState = _uiState.asStateFlow() - fun setRelation(relation: Relation) { - _relation.update { relation } + fun onNameChanged(value: String) { + _uiState.update { + it.copy( + name = + it.name.copy( + value = value, + isDirty = true, + error = null, + ), + ) + } } + + fun onRelationChanged(value: Relation) { + _uiState.update { it.copy(relation = value) } + } + + fun onContactFrequencyChanged(value: ContactFrequency) { + _uiState.update { it.copy(contactFrequency = value) } + } + + fun onBirthdayChanged(value: LocalDate?) { + _uiState.update { + it.copy( + birthday = + it.birthday.copy( + value = "", + isDirty = true, + error = null, + ), + ) + } + } + + fun onAnniversaryTitleChanged( + index: Int, + value: String, + ) { + _uiState.update { + it.copy( + anniversaries = + it.anniversaries.toMutableList().apply { + this[index] = + this[index].copy( + title = + this[index].title.copy( + value = value, + isDirty = true, + error = null, + ), + ) + }, + ) + } + } + + fun onAnniversaryDateChanged( + index: Int, + value: LocalDate?, + ) { + _uiState.update { + it.copy( + anniversaries = + it.anniversaries.toMutableList().apply { + this[index] = + this[index].copy( + date = + this[index].date.copy( + value = "", + isDirty = true, + error = null, + ), + ) + }, + ) + } + } + + fun onAddAnniversary() { + _uiState.update { it.copy(anniversaries = it.anniversaries + AnniversaryUIState()) } + } + + fun onRemoveAnniversary(index: Int) { + _uiState.update { + it.copy(anniversaries = it.anniversaries.toMutableList().apply { removeAt(index) }) + } + } + + fun onMemoChanged(value: String) { + _uiState.update { + it.copy( + memo = + it.memo.copy( + value = value, + isDirty = true, + error = null, + ), + ) + } + } + +// fun onSubmit() { +// val model = _uiState.value +// val validated = +// model.copy( +// name = model.name.copy(error = if (model.name.value.isBlank()) "이름을 입력해주세요." else null), +// anniversaries = +// model.anniversaries.map { anniversary -> +// anniversary.copy( +// title = anniversary.title.copy(error = if (anniversary.title.value.isBlank()) "기념일 이름을 입력해주세요." else null), +// date = anniversary.date.copy(error = if (anniversary.date.value == null) "날짜를 선택해주세요." else null), +// ) +// }, +// ) +// +// // _uiState.update { it.copy( = validated) } +// +// // Validation 성공 시 Repository 저장 +// if (validated.name.error == null && +// validated.anniversaries.all { it.title.error == null && it.date.error == null } +// ) { +// _uiState.update { it.copy(isSubmitting = true) } +// viewModelScope.launch { +// // repository.save(validated.toDomain()) +// _uiState.update { it.copy(isSubmitting = false, isSuccess = true) } +// } +// } +// } +// +// private fun update(transform: FriendUiModel.() -> FriendUiModel) { +// _uiState.update { state -> state.copy(model = state.model.transform()) } +// } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt index 7fa1db3e..737f9814 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt @@ -1,30 +1,86 @@ package com.alarmy.near.presentation.feature.friendprofileedittor.navigation +import android.os.Build +import android.os.Parcelable import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions +import androidx.navigation.NavType import androidx.navigation.compose.composable +import androidx.savedstate.SavedState import com.alarmy.near.model.Friend import com.alarmy.near.presentation.feature.friendprofileedittor.FriendProfileEditorRoute +import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.RouteFriendProfileEditor.Companion.routeTypeMap +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.reflect.KType +import kotlin.reflect.typeOf @Serializable +@Parcelize data class RouteFriendProfileEditor( val friend: Friend, -) +) : Parcelable { + companion object { + val routeTypeMap = + mapOf>( + typeOf() to FriendType, + ) + } +} -fun NavController.navigateToFriendProfileEditor(navOptions: NavOptions) { - navigate(RouteFriendProfileEditor, navOptions) +fun NavController.navigateToFriendProfileEditor( + friend: Friend, + navOptions: NavOptions? = null, +) { + navigate( + RouteFriendProfileEditor( + friend = friend, + ), + navOptions, + ) } fun NavGraphBuilder.friendProfileEditorNavGraph( onShowErrorSnackBar: (throwable: Throwable?) -> Unit, onClickBackButton: () -> Unit = {}, ) { - composable { backStackEntry -> + composable( + typeMap = + routeTypeMap, + ) { backStackEntry -> FriendProfileEditorRoute( onShowErrorSnackBar = onShowErrorSnackBar, onClickBackButton = onClickBackButton, ) } } + +internal val FriendType = + object : NavType( + isNullableAllowed = false, + ) { + override fun put( + bundle: SavedState, + key: String, + value: Friend, + ) { + bundle.putParcelable(key, value) + } + + override fun get( + bundle: SavedState, + key: String, + ): Friend? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + bundle.getParcelable(key, Friend::class.java) + } else { + @Suppress("DEPRECATION") + bundle.getParcelable(key) + } + + override fun parseValue(value: String): Friend = Json.decodeFromString(value) + + override fun serializeAsValue(value: Friend): String = Json.encodeToString(value) + } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt new file mode 100644 index 00000000..f4472d7a --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt @@ -0,0 +1,42 @@ +package com.alarmy.near.presentation.feature.friendprofileedittor.uistate + +import com.alarmy.near.model.Anniversary +import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.Friend +import com.alarmy.near.model.Relation + +data class FriendProfileEditorUIState( + val name: InputField = InputField(""), + val relation: Relation = Relation.FRIEND, + val contactFrequency: ContactFrequency, + val birthday: InputField = InputField(null), + val anniversaries: List = emptyList(), + val memo: InputField = InputField(null), +) + +data class AnniversaryUIState( + val title: InputField = InputField(""), + val date: InputField = InputField(null), +) + +data class InputField( + val value: T, + val error: String? = null, // null이면 유효한 상태 + val isDirty: Boolean = false, // 유저가 입력을 시도했는지 +) + +fun Friend.toUiModel(): FriendProfileEditorUIState = + FriendProfileEditorUIState( + name = InputField(name), + relation = relation, + contactFrequency = contactFrequency, + birthday = InputField(birthday), + anniversaries = anniversaryList.map { it.toUiModel() }, + memo = InputField(memo) + ) + +fun Anniversary.toUiModel(): AnniversaryUIState = + AnniversaryUIState( + title = InputField(title), + date = InputField(date) + ) 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 39b75bf0..5576182b 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt @@ -1,7 +1,6 @@ package com.alarmy.near.presentation.feature.main import android.content.Intent -import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -11,6 +10,7 @@ import androidx.navigation.compose.NavHost import com.alarmy.near.presentation.feature.friendprofile.navigation.friendProfileNavGraph import com.alarmy.near.presentation.feature.friendprofile.navigation.navigateToFriendProfile import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.friendProfileEditorNavGraph +import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.navigateToFriendProfileEditor import com.alarmy.near.presentation.feature.home.navigation.RouteHome import com.alarmy.near.presentation.feature.home.navigation.homeNavGraph @@ -43,6 +43,8 @@ internal fun NearNavHost( data = "sms:$phoneNumber".toUri() } context.startActivity(intent) + }, onEditFriendInfo = { + navController.navigateToFriendProfileEditor(friend = it) }) friendProfileEditorNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = { navController.popBackStack() From e0662103a8a7e41de48606b370e7720164edbe16 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sat, 30 Aug 2025 20:27:29 +0900 Subject: [PATCH 031/100] =?UTF-8?q?feat:=20=EC=A0=95=EB=B3=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FriendProfileEditorScreen.kt | 252 ++++++++++-------- .../FriendProfileEditorViewModel.kt | 56 ++-- .../component/NearDatePicker.kt | 26 +- .../uistate/FriendProfileEditorUIState.kt | 21 +- 4 files changed, 220 insertions(+), 135 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index 91e7f658..ac5bb3fc 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -64,6 +64,13 @@ fun FriendProfileEditorRoute( onNameChanged = viewModel::onNameChanged, friendProfileEditorUIState = friendProfileEditorUIState.value, onRelationChanged = viewModel::onRelationChanged, + onReminderIntervalChanged = viewModel::onRemindIntervalChanged, + onBirthdayChanged = viewModel::onBirthdayChanged, + onAnniversaryNameChange = viewModel::onAnniversaryTitleChanged, + onAnniversaryDateSelected = viewModel::onAnniversaryDateChanged, + onRemoveAnniversary = viewModel::onRemoveAnniversary, + onAddAnniversary = viewModel::onAddAnniversary, + onMemoChanged = viewModel::onMemoChanged, ) } @@ -75,14 +82,23 @@ fun FriendProfileEditorScreen( onClickBackButton: () -> Unit = {}, onNameChanged: (String) -> Unit = {}, onRelationChanged: (Relation) -> Unit = {}, + onReminderIntervalChanged: (ReminderInterval) -> Unit = {}, + onBirthdayChanged: (Long) -> Unit = {}, + onAnniversaryNameChange: (index: Int, name: String) -> Unit = { _, _ -> }, + onAnniversaryDateSelected: (index: Int, dataTimeMillis: Long) -> Unit = { _, _ -> }, + onRemoveAnniversary: (index: Int) -> Unit = { _ -> }, + onAddAnniversary: () -> Unit = {}, + onMemoChanged: (String) -> Unit = {}, ) { - val isErrorMessageVisible = remember { mutableStateOf(false) } val density = LocalDensity.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } val showBottomSheet = remember { mutableStateOf(false) } if (showBottomSheet.value) { ReminderIntervalBottomSheet(onDismissRequest = { showBottomSheet.value = false + }, onSelectReminderInterval = { + onReminderIntervalChanged(it) + showBottomSheet.value = false }) } Column( @@ -143,7 +159,7 @@ fun FriendProfileEditorScreen( }, ) } - if (isErrorMessageVisible.value) { + if (friendProfileEditorUIState.name.error) { Spacer(modifier = Modifier.height(8.dp)) Text( "이름을 입력해주세요.", @@ -261,7 +277,9 @@ fun FriendProfileEditorScreen( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = stringResource(friendProfileEditorUIState.contactFrequency.reminderInterval.labelRes) + "()", + text = + stringResource(friendProfileEditorUIState.contactFrequency.reminderInterval.labelRes) + + "(${friendProfileEditorUIState.contactFrequency.dayOfWeek} 마다)", style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLACK_1A1A1A, ) @@ -286,7 +304,11 @@ fun FriendProfileEditorScreen( NearDatePicker( datePickerState = datePickerState, onDismiss = { birthdayDatePickerState.value = false }, - onDateSelected = {}, + onDateSelected = { + it?.let { + onBirthdayChanged(it) + } + }, ) } Text( @@ -315,14 +337,15 @@ fun FriendProfileEditorScreen( end = 12.dp, top = 14.dp, bottom = 14.dp, - ).onNoRippleClick({ + ) + .onNoRippleClick({ birthdayDatePickerState.value = true }), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - "2020.06.24", + friendProfileEditorUIState.birthday.value ?: "날짜 선택", style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLACK_1A1A1A, ) @@ -344,7 +367,10 @@ fun FriendProfileEditorScreen( color = NearTheme.colors.GRAY01_888888, ) Text( - modifier = Modifier.onNoRippleClick(onClick = {}), + modifier = + Modifier.onNoRippleClick(onClick = { + onAddAnniversary() + }), text = "추가하기", style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLUE01_5AA2E9, @@ -352,110 +378,127 @@ fun FriendProfileEditorScreen( } Spacer(modifier = Modifier.height(16.dp)) } - LazyColumn( - modifier = Modifier.background(color = NearTheme.colors.BG02_F4F9FD), - contentPadding = - PaddingValues( - top = 20.dp, - bottom = 32.dp, - start = 24.dp, - end = 20.dp, - ), - verticalArrangement = Arrangement.spacedBy(32.dp), - ) { - item { - Column { - val anniversaryDatePickerState = remember { mutableStateOf(false) } - val datePickerState = - rememberDatePickerState() - if (anniversaryDatePickerState.value) { - NearDatePicker( - datePickerState = datePickerState, - onDismiss = { anniversaryDatePickerState.value = false }, - onDateSelected = {}, - ) - } - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "기념일 이름", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(23.dp)) - NearTextField( - modifier = Modifier.weight(1f), - value = "가나다", - onValueChange = { - }, - ) - } - if (isErrorMessageVisible.value) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - "이름을 입력해주세요.", - style = NearTheme.typography.FC_12_MEDIUM, - color = NearTheme.colors.NEGATIVE_F04E4E, - ) - } - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = - Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "날짜", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(62.dp)) - Surface( + if (friendProfileEditorUIState.anniversaries.isNotEmpty()) { + LazyColumn( + modifier = Modifier.background(color = NearTheme.colors.BG02_F4F9FD), + contentPadding = + PaddingValues( + top = 20.dp, + bottom = 32.dp, + start = 24.dp, + end = 20.dp, + ), + verticalArrangement = Arrangement.spacedBy(32.dp), + ) { + items( + count = friendProfileEditorUIState.anniversaries.size, + ) { index -> + Column { + val anniversaryDatePickerState = remember { mutableStateOf(false) } + val datePickerState = + rememberDatePickerState() + if (anniversaryDatePickerState.value) { + NearDatePicker( + datePickerState = datePickerState, + onDismiss = { anniversaryDatePickerState.value = false }, + onDateSelected = { + it?.let { + onAnniversaryDateSelected(index, it) + } + }, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "기념일 이름", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(23.dp)) + NearTextField( + modifier = Modifier.weight(1f), + value = friendProfileEditorUIState.anniversaries[index].title.value, + onValueChange = { + onAnniversaryNameChange(index, it) + }, + ) + } + if (friendProfileEditorUIState.anniversaries[index].title.error) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + "이름을 입력해주세요.", + style = NearTheme.typography.FC_12_MEDIUM, + color = NearTheme.colors.NEGATIVE_F04E4E, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Row( modifier = - modifier - .weight(1f) - .onNoRippleClick(onClick = { - anniversaryDatePickerState.value = true - }), - shape = RoundedCornerShape(12.dp), - border = - BorderStroke( - width = 1.dp, - color = NearTheme.colors.GRAY03_EBEBEB, - ), - color = NearTheme.colors.WHITE_FFFFFF, + Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, ) { - Row( + Text( + "날짜", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(62.dp)) + Surface( modifier = - Modifier.padding( - start = 16.dp, - end = 12.dp, - top = 14.dp, - bottom = 14.dp, + modifier + .weight(1f) + .onNoRippleClick(onClick = { + anniversaryDatePickerState.value = true + }), + shape = RoundedCornerShape(12.dp), + border = + BorderStroke( + width = 1.dp, + color = NearTheme.colors.GRAY03_EBEBEB, ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + color = NearTheme.colors.WHITE_FFFFFF, ) { - Text( - "2020.06.24", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - Image( - painter = painterResource(id = R.drawable.ic_24_down), - contentDescription = null, - ) + Row( + modifier = + Modifier.padding( + start = 16.dp, + end = 12.dp, + top = 14.dp, + bottom = 14.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + friendProfileEditorUIState.anniversaries[index].date.value + ?: "날짜 선택", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + Image( + painter = painterResource(id = R.drawable.ic_24_down), + contentDescription = null, + ) + } } } + Spacer(modifier = Modifier.height(32.dp)) + Text( + modifier = + Modifier + .fillMaxWidth() + .onNoRippleClick( + onClick = { + onRemoveAnniversary(index) + }, + ), + textAlign = TextAlign.End, + text = "삭제하기", + textDecoration = TextDecoration.Underline, + color = NearTheme.colors.GRAY01_888888, + ) } - Spacer(modifier = Modifier.height(32.dp)) - Text( - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.End, - text = "삭제하기", - textDecoration = TextDecoration.Underline, - color = NearTheme.colors.GRAY01_888888, - ) } } } @@ -478,8 +521,9 @@ fun FriendProfileEditorScreen( Modifier .weight(1f) .height(180.dp), - value = "", + value = friendProfileEditorUIState.memo.value ?: "", onValueChange = { + onMemoChanged(it) }, placeHolderText = "꼭 기억해야 할 내용을 기록해보세요.\n" + diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt index 0e50341f..2a2b515e 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt @@ -3,9 +3,9 @@ package com.alarmy.near.presentation.feature.friendprofileedittor import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.navigation.toRoute -import com.alarmy.near.model.ContactFrequency import com.alarmy.near.model.Friend import com.alarmy.near.model.Relation +import com.alarmy.near.model.ReminderInterval import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.RouteFriendProfileEditor import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.AnniversaryUIState import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIState @@ -14,7 +14,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import java.time.LocalDate +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import javax.inject.Inject @HiltViewModel @@ -25,19 +27,25 @@ class FriendProfileEditorViewModel ) : ViewModel() { private val routeFriendProfileEditor: RouteFriendProfileEditor = savedStateHandle.toRoute(RouteFriendProfileEditor.routeTypeMap) - private val friend: Friend = routeFriendProfileEditor.friend + private val friend: Friend = + routeFriendProfileEditor.friend.apply { + copy() + } private val _uiState: MutableStateFlow = MutableStateFlow(friend.toUiModel()) val uiState = _uiState.asStateFlow() fun onNameChanged(value: String) { + if (value.length > MAX_NAME_LENGTH) { + return + } _uiState.update { it.copy( name = it.name.copy( value = value, isDirty = true, - error = null, + error = value.isEmpty(), ), ) } @@ -47,18 +55,24 @@ class FriendProfileEditorViewModel _uiState.update { it.copy(relation = value) } } - fun onContactFrequencyChanged(value: ContactFrequency) { - _uiState.update { it.copy(contactFrequency = value) } + fun onRemindIntervalChanged(value: ReminderInterval) { + _uiState.update { + it.copy( + contactFrequency = + it.contactFrequency.copy( + reminderInterval = value, + ), + ) + } } - fun onBirthdayChanged(value: LocalDate?) { + fun onBirthdayChanged(value: Long) { _uiState.update { it.copy( birthday = it.birthday.copy( - value = "", + value = convertMillisToDate(value), isDirty = true, - error = null, ), ) } @@ -78,7 +92,7 @@ class FriendProfileEditorViewModel this[index].title.copy( value = value, isDirty = true, - error = null, + error = value.isEmpty(), ), ) }, @@ -88,7 +102,7 @@ class FriendProfileEditorViewModel fun onAnniversaryDateChanged( index: Int, - value: LocalDate?, + value: Long, ) { _uiState.update { it.copy( @@ -98,9 +112,8 @@ class FriendProfileEditorViewModel this[index].copy( date = this[index].date.copy( - value = "", + value = convertMillisToDate(value), isDirty = true, - error = null, ), ) }, @@ -118,19 +131,28 @@ class FriendProfileEditorViewModel } } - fun onMemoChanged(value: String) { + fun onMemoChanged(value: String?) { + value?.length?.let { + if (it > MAX_MEMO_LENGTH) { + return + } + } _uiState.update { it.copy( memo = it.memo.copy( value = value, isDirty = true, - error = null, ), ) } } + private fun convertMillisToDate(millis: Long): String { + val formatter = SimpleDateFormat("yyyy.MM.dd", Locale.getDefault()) + return formatter.format(Date(millis)) + } + // fun onSubmit() { // val model = _uiState.value // val validated = @@ -162,4 +184,8 @@ class FriendProfileEditorViewModel // private fun update(transform: FriendUiModel.() -> FriendUiModel) { // _uiState.update { state -> state.copy(model = state.model.transform()) } // } + companion object { + private const val MAX_NAME_LENGTH = 20 + private const val MAX_MEMO_LENGTH = 200 +} } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/NearDatePicker.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/NearDatePicker.kt index a3bfca5b..2f23a951 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/NearDatePicker.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/NearDatePicker.kt @@ -27,9 +27,10 @@ fun NearDatePicker( onDismiss: () -> Unit, ) { DatePickerDialog( - colors = DatePickerDefaults.colors().copy( - containerColor = NearTheme.colors.WHITE_FFFFFF, - ), + colors = + DatePickerDefaults.colors().copy( + containerColor = NearTheme.colors.WHITE_FFFFFF, + ), onDismissRequest = onDismiss, confirmButton = { TextButton(onClick = { @@ -45,21 +46,22 @@ fun NearDatePicker( } }, ) { - DatePicker(state = datePickerState, title = null, headline = null, showModeToggle = false, + DatePicker( + state = datePickerState, + title = null, + headline = null, + showModeToggle = false, dateFormatter = remember { DatePickerDefaults.dateFormatter() }, - colors = DatePickerDefaults.colors().copy( - containerColor = NearTheme.colors.WHITE_FFFFFF, - )) + colors = + DatePickerDefaults.colors().copy( + containerColor = NearTheme.colors.WHITE_FFFFFF, + ), + ) } } -private fun convertMillisToDate(millis: Long): String { - val formatter = SimpleDateFormat("MM/dd/yyyy", Locale.getDefault()) - return formatter.format(Date(millis)) -} - @OptIn(ExperimentalMaterial3Api::class) @Preview(showBackground = true) @Composable diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt index f4472d7a..282a79d7 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt @@ -21,7 +21,7 @@ data class AnniversaryUIState( data class InputField( val value: T, - val error: String? = null, // null이면 유효한 상태 + val error: Boolean = false, // null이면 유효한 상태 val isDirty: Boolean = false, // 유저가 입력을 시도했는지 ) @@ -29,14 +29,27 @@ fun Friend.toUiModel(): FriendProfileEditorUIState = FriendProfileEditorUIState( name = InputField(name), relation = relation, - contactFrequency = contactFrequency, + contactFrequency = + contactFrequency.copy( + dayOfWeek = + when (contactFrequency.dayOfWeek) { + "MONDAY" -> "월요일" + "TUESDAY" -> "화요일" + "WEDNESDAY" -> "수요일" + "THURSDAY" -> "목요일" + "FRIDAY" -> "금요일" + "SATURDAY" -> "토요일" + "SUNDAY" -> "일요일" + else -> IllegalStateException("없는 타입 입니다.") + } as String, + ), birthday = InputField(birthday), anniversaries = anniversaryList.map { it.toUiModel() }, - memo = InputField(memo) + memo = InputField(memo), ) fun Anniversary.toUiModel(): AnniversaryUIState = AnniversaryUIState( title = InputField(title), - date = InputField(date) + date = InputField(date), ) From 00746955565ee63b345c10edbe374af2b9198eef Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 31 Aug 2025 16:08:36 +0900 Subject: [PATCH 032/100] =?UTF-8?q?feat:=20=EC=A0=95=EB=B3=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/alarmy/near/model/Friend.kt | 4 +- .../near/network/request/FriendRequest.kt | 4 +- .../FriendProfileEditorScreen.kt | 9 +++- .../FriendProfileEditorViewModel.kt | 42 +++++++++++++++---- .../uistate/FriendProfileEditorUIState.kt | 38 +++++++++++++++++ 5 files changed, 84 insertions(+), 13 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt index 14c63a49..8cba165a 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt @@ -42,7 +42,7 @@ data class ContactFrequency( @Serializable @Parcelize data class Anniversary( - val id: Int, + val id: Int? = null, val title: String, - val date: String, + val date: String? = null, ) : Parcelable diff --git a/Near/app/src/main/java/com/alarmy/near/network/request/FriendRequest.kt b/Near/app/src/main/java/com/alarmy/near/network/request/FriendRequest.kt index 1aa1099e..fc9878e2 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/request/FriendRequest.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/request/FriendRequest.kt @@ -21,7 +21,7 @@ data class ContactFrequencyRequest( @Serializable data class AnniversaryRequest( - val id: Int, + val id: Int? = null, val title: String, - val date: String, + val date: String? = null, ) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index ac5bb3fc..8aaa835c 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -71,6 +71,7 @@ fun FriendProfileEditorRoute( onRemoveAnniversary = viewModel::onRemoveAnniversary, onAddAnniversary = viewModel::onAddAnniversary, onMemoChanged = viewModel::onMemoChanged, + onSubmit = viewModel::onSubmit, ) } @@ -89,6 +90,7 @@ fun FriendProfileEditorScreen( onRemoveAnniversary: (index: Int) -> Unit = { _ -> }, onAddAnniversary: () -> Unit = {}, onMemoChanged: (String) -> Unit = {}, + onSubmit: () -> Unit = {}, ) { val density = LocalDensity.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } @@ -114,6 +116,10 @@ fun FriendProfileEditorScreen( onClickBackButton = onClickBackButton, menuButton = { Text( + modifier = + Modifier.onNoRippleClick(onClick = { + onSubmit() + }), text = "완료", style = NearTheme.typography.B1_16_BOLD, color = NearTheme.colors.BLACK_1A1A1A, @@ -337,8 +343,7 @@ fun FriendProfileEditorScreen( end = 12.dp, top = 14.dp, bottom = 14.dp, - ) - .onNoRippleClick({ + ).onNoRippleClick({ birthdayDatePickerState.value = true }), verticalAlignment = Alignment.CenterVertically, diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt index 2a2b515e..37c88734 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt @@ -2,18 +2,22 @@ package com.alarmy.near.presentation.feature.friendprofileedittor import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute +import com.alarmy.near.data.repository.FriendRepository import com.alarmy.near.model.Friend import com.alarmy.near.model.Relation import com.alarmy.near.model.ReminderInterval import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.RouteFriendProfileEditor import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.AnniversaryUIState import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIState +import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.toModel import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.toUiModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -24,6 +28,7 @@ class FriendProfileEditorViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + private val friendRepository: FriendRepository, ) : ViewModel() { private val routeFriendProfileEditor: RouteFriendProfileEditor = savedStateHandle.toRoute(RouteFriendProfileEditor.routeTypeMap) @@ -134,9 +139,9 @@ class FriendProfileEditorViewModel fun onMemoChanged(value: String?) { value?.length?.let { if (it > MAX_MEMO_LENGTH) { - return + return + } } - } _uiState.update { it.copy( memo = @@ -150,10 +155,33 @@ class FriendProfileEditorViewModel private fun convertMillisToDate(millis: Long): String { val formatter = SimpleDateFormat("yyyy.MM.dd", Locale.getDefault()) - return formatter.format(Date(millis)) - } + return formatter.format(Date(millis)) + } -// fun onSubmit() { + fun onSubmit() { + val updatedFriend = _uiState.value + if (updatedFriend.name.error || updatedFriend.anniversaries.any { it.title.error }) { + // error + return + } + + viewModelScope.launch { + friendRepository + .updateFriend( + friendId = friend.friendId, + friend = + updatedFriend.toModel( + friendId = friend.friendId, + imageUrl = friend.imageUrl ?: "", + phone = friend.phone ?: "", + lastContactAt = friend.lastContactAt ?: "", + ), + ).collect { + } + } + } + + // fun onSubmit() { // val model = _uiState.value // val validated = // model.copy( @@ -186,6 +214,6 @@ class FriendProfileEditorViewModel // } companion object { private const val MAX_NAME_LENGTH = 20 - private const val MAX_MEMO_LENGTH = 200 -} + private const val MAX_MEMO_LENGTH = 200 + } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt index 282a79d7..3d2f2b04 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt @@ -53,3 +53,41 @@ fun Anniversary.toUiModel(): AnniversaryUIState = title = InputField(title), date = InputField(date), ) + +fun FriendProfileEditorUIState.toModel( + friendId: String, + imageUrl: String, + phone: String, + lastContactAt: String, +): Friend = + Friend( + name = name.value, + relation = relation, + contactFrequency = + contactFrequency.copy( + dayOfWeek = + when (contactFrequency.dayOfWeek) { + "월요일" -> "MONDAY" + "화요일" -> "TUESDAY" + "수요일" -> "WEDNESDAY" + "목요일" -> "THURSDAY" + "금요일" -> "FRIDAY" + "토요일" -> "SATURDAY" + "일요일" -> "SUNDAY" + else -> IllegalStateException("없는 타입 입니다.") + } as String, + ), + birthday = birthday.value?.replace(".", "-"), + anniversaryList = + anniversaries.map { + Anniversary( + title = it.title.value, + date = it.date.value?.replace(".", "-"), + ) + }, + friendId = friendId, + imageUrl = imageUrl, + memo = memo.value, + phone = phone, + lastContactAt = lastContactAt, + ) From 85a40ee7c18032ce7e583e10ea53a013f3a11011 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 31 Aug 2025 22:23:03 +0900 Subject: [PATCH 033/100] =?UTF-8?q?feat:=20=EC=9D=B4=EC=A0=84=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../friendprofile/FriendProfileViewModel.kt | 13 +++- .../navigation/FriendProfileNavigation.kt | 23 +++++++- .../FriendProfileEditorScreen.kt | 29 +++++++++ .../FriendProfileEditorViewModel.kt | 59 +++++++------------ .../navigation/FriendProfileNavigation.kt | 4 ++ .../uistate/FriendProfileEditorUIEvent.kt | 17 ++++++ .../presentation/feature/main/NearNavHost.kt | 6 ++ 7 files changed, 110 insertions(+), 41 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIEvent.kt diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt index 4982e655..ec245001 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.alarmy.near.data.repository.FriendRepository +import com.alarmy.near.model.Friend import com.alarmy.near.model.FriendRecord import com.alarmy.near.presentation.feature.friendprofile.navigation.RouteFriendProfile import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendProfileUIEvent @@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.Locale @@ -32,7 +34,8 @@ class FriendProfileViewModel savedStateHandle: SavedStateHandle, private val friendRepository: FriendRepository, ) : ViewModel() { - private val friendId: String = savedStateHandle.toRoute().friendId + private val friendId: String = + savedStateHandle.toRoute().friendId private val _uiEvent = Channel() val uiEvent = _uiEvent.receiveAsFlow() private val _friendFlow: MutableStateFlow = MutableStateFlow(FriendState.Loading) @@ -133,5 +136,11 @@ class FriendProfileViewModel val today = LocalDate.now() val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.KOREA) return today.format(formatter) - } + } + + fun updateFriend(friend: Friend) { + if (_friendFlow.value is FriendState.Success) { + _friendFlow.update { (it as FriendState.Success).copy(friend = friend) } + } + } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt index c28df80c..d22a48d3 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt @@ -1,17 +1,32 @@ package com.alarmy.near.presentation.feature.friendprofile.navigation +import android.os.Build +import android.os.Parcelable +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions +import androidx.navigation.NavType import androidx.navigation.compose.composable +import androidx.navigation.toRoute +import androidx.savedstate.SavedState import com.alarmy.near.model.Friend import com.alarmy.near.presentation.feature.friendprofile.FriendProfileRoute +import com.alarmy.near.presentation.feature.friendprofile.FriendProfileViewModel +import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.FRIEND_PROFILE_EDIT_COMPLETE_KEY +import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.FriendType +import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.RouteFriendProfileEditor +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.reflect.KType +import kotlin.reflect.typeOf +@Parcelize @Serializable data class RouteFriendProfile( val friendId: String, -) +) : Parcelable fun NavController.navigateToFriendProfile( friendId: String, @@ -28,7 +43,13 @@ fun NavGraphBuilder.friendProfileNavGraph( onClickMessageButton: (phoneNumber: String) -> Unit = {}, ) { composable { backStackEntry -> + val viewModel: FriendProfileViewModel = hiltViewModel() + val friend = backStackEntry.savedStateHandle.get(FRIEND_PROFILE_EDIT_COMPLETE_KEY) + friend?.let { + viewModel.updateFriend(it) + } FriendProfileRoute( + viewModel = viewModel, onShowErrorSnackBar = onShowErrorSnackBar, onClickBackButton = onClickBackButton, onEditFriendInfo = onEditFriendInfo, diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index 8aaa835c..9ee0be5d 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -1,5 +1,6 @@ package com.alarmy.near.presentation.feature.friendprofileedittor +import android.util.Log import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -22,6 +23,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -40,10 +42,12 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.Friend import com.alarmy.near.model.Relation import com.alarmy.near.model.ReminderInterval import com.alarmy.near.presentation.feature.friendprofileedittor.component.NearDatePicker import com.alarmy.near.presentation.feature.friendprofileedittor.component.ReminderIntervalBottomSheet +import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIEvent import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIState import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar import com.alarmy.near.presentation.ui.component.radiobutton.NearSmallRadioButton @@ -51,14 +55,39 @@ import com.alarmy.near.presentation.ui.component.textfield.NearLimitedTextField import com.alarmy.near.presentation.ui.component.textfield.NearTextField import com.alarmy.near.presentation.ui.extension.onNoRippleClick import com.alarmy.near.presentation.ui.theme.NearTheme +import kotlinx.coroutines.launch @Composable fun FriendProfileEditorRoute( viewModel: FriendProfileEditorViewModel = hiltViewModel(), onShowErrorSnackBar: (throwable: Throwable?) -> Unit, onClickBackButton: () -> Unit = {}, + onSuccessEdit: (Friend) -> Unit = {}, ) { val friendProfileEditorUIState = viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(viewModel.uiEvent) { + launch { + viewModel.uiEvent.collect { event -> + when (event) { + FriendProfileEditorUIEvent.WarningExit -> { + } + + is FriendProfileEditorUIEvent.FriendProfileEditFailure -> { + onShowErrorSnackBar(event.throwable) + } + + FriendProfileEditorUIEvent.FriendProfileEditNetworkError -> { + onShowErrorSnackBar(IllegalStateException("네트워크 에러가 발생했습니다.")) + } + + is FriendProfileEditorUIEvent.FriendProfileEditSuccess -> { + Log.d("FriendProfileEditorRoute", "FriendProfileEditSuccess") + onSuccessEdit(event.friend) + } + } + } + } + } FriendProfileEditorScreen( onClickBackButton = onClickBackButton, onNameChanged = viewModel::onNameChanged, diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt index 37c88734..d0657435 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt @@ -10,12 +10,16 @@ import com.alarmy.near.model.Relation import com.alarmy.near.model.ReminderInterval import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.RouteFriendProfileEditor import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.AnniversaryUIState +import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIEvent import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIState import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.toModel import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.toUiModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.text.SimpleDateFormat @@ -30,16 +34,16 @@ class FriendProfileEditorViewModel savedStateHandle: SavedStateHandle, private val friendRepository: FriendRepository, ) : ViewModel() { - private val routeFriendProfileEditor: RouteFriendProfileEditor = - savedStateHandle.toRoute(RouteFriendProfileEditor.routeTypeMap) private val friend: Friend = - routeFriendProfileEditor.friend.apply { - copy() - } + savedStateHandle.toRoute(RouteFriendProfileEditor.routeTypeMap).friend + private val _uiState: MutableStateFlow = MutableStateFlow(friend.toUiModel()) val uiState = _uiState.asStateFlow() + private val _uiEvent = Channel() + val uiEvent = _uiEvent.receiveAsFlow() + fun onNameChanged(value: String) { if (value.length > MAX_NAME_LENGTH) { return @@ -158,9 +162,17 @@ class FriendProfileEditorViewModel return formatter.format(Date(millis)) } + fun onExit() { + if (uiState.value.anniversaries.any { it.title.isDirty || it.date.isDirty } || + uiState.value.name.isDirty || uiState.value.memo.isDirty || + uiState.value.contactFrequency != friend.contactFrequency || uiState.value.birthday.isDirty + ) { + } + } + fun onSubmit() { val updatedFriend = _uiState.value - if (updatedFriend.name.error || updatedFriend.anniversaries.any { it.title.error }) { + if ((updatedFriend.name.error || updatedFriend.anniversaries.any { it.title.error })) { // error return } @@ -176,42 +188,13 @@ class FriendProfileEditorViewModel phone = friend.phone ?: "", lastContactAt = friend.lastContactAt ?: "", ), - ).collect { + ).catch { + }.collect { + _uiEvent.send(FriendProfileEditorUIEvent.FriendProfileEditSuccess(it)) } } } - // fun onSubmit() { -// val model = _uiState.value -// val validated = -// model.copy( -// name = model.name.copy(error = if (model.name.value.isBlank()) "이름을 입력해주세요." else null), -// anniversaries = -// model.anniversaries.map { anniversary -> -// anniversary.copy( -// title = anniversary.title.copy(error = if (anniversary.title.value.isBlank()) "기념일 이름을 입력해주세요." else null), -// date = anniversary.date.copy(error = if (anniversary.date.value == null) "날짜를 선택해주세요." else null), -// ) -// }, -// ) -// -// // _uiState.update { it.copy( = validated) } -// -// // Validation 성공 시 Repository 저장 -// if (validated.name.error == null && -// validated.anniversaries.all { it.title.error == null && it.date.error == null } -// ) { -// _uiState.update { it.copy(isSubmitting = true) } -// viewModelScope.launch { -// // repository.save(validated.toDomain()) -// _uiState.update { it.copy(isSubmitting = false, isSuccess = true) } -// } -// } -// } -// -// private fun update(transform: FriendUiModel.() -> FriendUiModel) { -// _uiState.update { state -> state.copy(model = state.model.transform()) } -// } companion object { private const val MAX_NAME_LENGTH = 20 private const val MAX_MEMO_LENGTH = 200 diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt index 737f9814..1fd39a14 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt @@ -17,6 +17,8 @@ import kotlinx.serialization.json.Json import kotlin.reflect.KType import kotlin.reflect.typeOf +const val FRIEND_PROFILE_EDIT_COMPLETE_KEY = "FRIEND_PROFILE_EDIT_COMPLETE_KEY" + @Serializable @Parcelize data class RouteFriendProfileEditor( @@ -45,6 +47,7 @@ fun NavController.navigateToFriendProfileEditor( fun NavGraphBuilder.friendProfileEditorNavGraph( onShowErrorSnackBar: (throwable: Throwable?) -> Unit, onClickBackButton: () -> Unit = {}, + onSuccessEdit: (Friend) -> Unit = {}, ) { composable( typeMap = @@ -53,6 +56,7 @@ fun NavGraphBuilder.friendProfileEditorNavGraph( FriendProfileEditorRoute( onShowErrorSnackBar = onShowErrorSnackBar, onClickBackButton = onClickBackButton, + onSuccessEdit = onSuccessEdit, ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIEvent.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIEvent.kt new file mode 100644 index 00000000..a8ffe2c7 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIEvent.kt @@ -0,0 +1,17 @@ +package com.alarmy.near.presentation.feature.friendprofileedittor.uistate + +import com.alarmy.near.model.Friend + +sealed interface FriendProfileEditorUIEvent { + data class FriendProfileEditSuccess( + val friend: Friend, + ) : FriendProfileEditorUIEvent + + data class FriendProfileEditFailure( + val throwable: Throwable, + ) : FriendProfileEditorUIEvent + + data object FriendProfileEditNetworkError : FriendProfileEditorUIEvent + + data object WarningExit : FriendProfileEditorUIEvent +} 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 5576182b..88c48a8e 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 @@ -9,6 +9,7 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import com.alarmy.near.presentation.feature.friendprofile.navigation.friendProfileNavGraph import com.alarmy.near.presentation.feature.friendprofile.navigation.navigateToFriendProfile +import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.FRIEND_PROFILE_EDIT_COMPLETE_KEY import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.friendProfileEditorNavGraph import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.navigateToFriendProfileEditor import com.alarmy.near.presentation.feature.home.navigation.RouteHome @@ -47,6 +48,11 @@ internal fun NearNavHost( navController.navigateToFriendProfileEditor(friend = it) }) friendProfileEditorNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = { + }, onSuccessEdit = { + navController.previousBackStackEntry?.savedStateHandle?.set( + FRIEND_PROFILE_EDIT_COMPLETE_KEY, + it, + ) navController.popBackStack() }) homeNavGraph( From e5fd302bdc180be960c324c3907d2c2cb4c612e4 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 31 Aug 2025 22:27:01 +0900 Subject: [PATCH 034/100] =?UTF-8?q?fix:=20navigation=20imageUrl=20?= =?UTF-8?q?=EC=9D=B8=EC=BD=94=EB=94=A9=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../near/presentation/feature/main/NearNavHost.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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 88c48a8e..a3f4e2e7 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 @@ -14,6 +14,8 @@ 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.RouteHome import com.alarmy.near.presentation.feature.home.navigation.homeNavGraph +import java.net.URLEncoder +import java.nio.charset.StandardCharsets @Composable internal fun NearNavHost( @@ -45,7 +47,18 @@ internal fun NearNavHost( } context.startActivity(intent) }, onEditFriendInfo = { - navController.navigateToFriendProfileEditor(friend = it) + navController.navigateToFriendProfileEditor( + friend = + it.copy( + imageUrl = + it.imageUrl?.let { imageUrl -> + URLEncoder.encode( + imageUrl, + StandardCharsets.UTF_8.toString(), + ) + }, + ), + ) }) friendProfileEditorNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = { }, onSuccessEdit = { From 83465cb0eea9527c780ed72d4766785d4483b6af Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Mon, 1 Sep 2025 00:18:14 +0900 Subject: [PATCH 035/100] =?UTF-8?q?feat:=20=ED=99=94=EB=A9=B4=EC=9D=B4=20?= =?UTF-8?q?=EA=B8=B8=EC=96=B4=EC=A7=88=20=EA=B2=BD=EC=9A=B0=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../friendprofile/FriendProfileScreen.kt | 5 + .../FriendProfileEditorScreen.kt | 740 +++++++++--------- 2 files changed, 377 insertions(+), 368 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index a7eec3b5..73de2748 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -24,7 +24,9 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -151,12 +153,14 @@ fun FriendProfileScreen( ) { when (friendState) { is FriendState.Success -> { + val scrollState = rememberScrollState() val friend = friendState.friend Column( modifier = Modifier .align(Alignment.TopStart) .fillMaxSize() + .verticalScroll(scrollState) .background(NearTheme.colors.WHITE_FFFFFF), ) { if (recordSuccessDialogState) { @@ -397,6 +401,7 @@ fun FriendProfileScreen( } else { RecordTab(friendShipRecordState = friendShipRecordState) } + Spacer(modifier = Modifier.height(60.dp)) } NearSolidTypeButton( modifier = diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index 9ee0be5d..c78c0708 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -132,441 +132,445 @@ fun FriendProfileEditorScreen( showBottomSheet.value = false }) } - Column( + LazyColumn( modifier = - modifier + Modifier .fillMaxSize() .background(NearTheme.colors.WHITE_FFFFFF), ) { - Spacer(modifier = Modifier.padding(top = statusBarHeightDp)) - NearTopAppbar( - modifier = Modifier.padding(end = 24.dp), - title = "", - onClickBackButton = onClickBackButton, - menuButton = { - Text( - modifier = - Modifier.onNoRippleClick(onClick = { - onSubmit() - }), - text = "완료", - style = NearTheme.typography.B1_16_BOLD, - color = NearTheme.colors.BLACK_1A1A1A, - ) - }, - ) - Spacer(modifier = Modifier.height(16.dp)) - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(start = 24.dp, end = 20.dp), - ) { - Row( - modifier = - Modifier - .fillMaxWidth(), - ) { - Text( - modifier = Modifier.padding(top = 16.dp), - text = - buildAnnotatedString { - append("이름") - withStyle( - style = - SpanStyle( - color = NearTheme.colors.BLUE01_5AA2E9, - ), - ) { - append("*") - } - }, - textAlign = TextAlign.Center, - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(55.dp)) - NearTextField( - modifier = Modifier.weight(1f), - value = friendProfileEditorUIState.name.value, - onValueChange = { - onNameChanged(it) + if (friendProfileEditorUIState.anniversaries.isNotEmpty()) { + item { + Spacer(modifier = Modifier.padding(top = statusBarHeightDp)) + NearTopAppbar( + modifier = Modifier.padding(end = 24.dp), + title = "", + onClickBackButton = onClickBackButton, + menuButton = { + Text( + modifier = + Modifier.onNoRippleClick(onClick = { + onSubmit() + }), + text = "완료", + style = NearTheme.typography.B1_16_BOLD, + color = NearTheme.colors.BLACK_1A1A1A, + ) }, ) - } - if (friendProfileEditorUIState.name.error) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - "이름을 입력해주세요.", - style = NearTheme.typography.FC_12_MEDIUM, - color = NearTheme.colors.NEGATIVE_F04E4E, - ) - } - Spacer(modifier = Modifier.height(32.dp)) - Row( - modifier = - Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "관계", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(72.dp)) - Row( + Spacer(modifier = Modifier.height(16.dp)) + Column( modifier = Modifier - .weight(1f) - .padding(end = 35.dp), - horizontalArrangement = Arrangement.SpaceBetween, + .fillMaxWidth() + .padding(start = 24.dp, end = 20.dp), ) { Row( modifier = - Modifier.onNoRippleClick(onClick = { - onRelationChanged(Relation.FRIEND) - }), - verticalAlignment = Alignment.CenterVertically, + Modifier + .fillMaxWidth(), ) { - NearSmallRadioButton( - selected = friendProfileEditorUIState.relation == Relation.FRIEND, - onClick = {}, - ) - Spacer(modifier = Modifier.width(8.dp)) Text( - text = stringResource(R.string.friend_profile_editor_relation_freind), + modifier = Modifier.padding(top = 16.dp), + text = + buildAnnotatedString { + append("이름") + withStyle( + style = + SpanStyle( + color = NearTheme.colors.BLUE01_5AA2E9, + ), + ) { + append("*") + } + }, + textAlign = TextAlign.Center, style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - } - Row( - modifier = - Modifier.onNoRippleClick(onClick = { - onRelationChanged(Relation.FAMILY) - }), - verticalAlignment = Alignment.CenterVertically, - ) { - NearSmallRadioButton( - selected = friendProfileEditorUIState.relation == Relation.FAMILY, - onClick = {}, + color = NearTheme.colors.GRAY01_888888, ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.friend_profile_editor_relation_family), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, + Spacer(modifier = Modifier.width(55.dp)) + NearTextField( + modifier = Modifier.weight(1f), + value = friendProfileEditorUIState.name.value, + onValueChange = { + onNameChanged(it) + }, ) } - Row(verticalAlignment = Alignment.CenterVertically) { - NearSmallRadioButton( - selected = friendProfileEditorUIState.relation == Relation.ACQUAINTANCE, - onClick = { onRelationChanged(Relation.ACQUAINTANCE) }, - ) - Spacer(modifier = Modifier.width(8.dp)) + if (friendProfileEditorUIState.name.error) { + Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(R.string.friend_profile_editor_relation_acquaintance), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, + "이름을 입력해주세요.", + style = NearTheme.typography.FC_12_MEDIUM, + color = NearTheme.colors.NEGATIVE_F04E4E, ) } - } - } - Spacer(modifier = Modifier.height(33.dp)) - Row( - modifier = - Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "연락 주기", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(35.dp)) - Surface( - modifier = - modifier - .weight(1f) - .onNoRippleClick({ - showBottomSheet.value = true - }), - shape = RoundedCornerShape(12.dp), - border = - BorderStroke( - width = 1.dp, - color = NearTheme.colors.GRAY03_EBEBEB, - ), - color = NearTheme.colors.WHITE_FFFFFF, - ) { + Spacer(modifier = Modifier.height(32.dp)) Row( modifier = - Modifier.padding( - start = 16.dp, - end = 12.dp, - top = 14.dp, - bottom = 14.dp, - ), + Modifier + .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = - stringResource(friendProfileEditorUIState.contactFrequency.reminderInterval.labelRes) + - "(${friendProfileEditorUIState.contactFrequency.dayOfWeek} 마다)", + "관계", style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - Image( - painter = painterResource(id = R.drawable.ic_24_down), - contentDescription = null, + color = NearTheme.colors.GRAY01_888888, ) - } - } - } - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = - Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - val birthdayDatePickerState = remember { mutableStateOf(false) } - val datePickerState = - rememberDatePickerState() - if (birthdayDatePickerState.value) { - NearDatePicker( - datePickerState = datePickerState, - onDismiss = { birthdayDatePickerState.value = false }, - onDateSelected = { - it?.let { - onBirthdayChanged(it) + Spacer(modifier = Modifier.width(72.dp)) + Row( + modifier = + Modifier + .weight(1f) + .padding(end = 35.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = + Modifier.onNoRippleClick(onClick = { + onRelationChanged(Relation.FRIEND) + }), + verticalAlignment = Alignment.CenterVertically, + ) { + NearSmallRadioButton( + selected = friendProfileEditorUIState.relation == Relation.FRIEND, + onClick = {}, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.friend_profile_editor_relation_freind), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) } - }, - ) - } - Text( - "생일", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(62.dp)) - Surface( - modifier = - modifier - .weight(1f), - shape = RoundedCornerShape(12.dp), - border = - BorderStroke( - width = 1.dp, - color = NearTheme.colors.GRAY03_EBEBEB, - ), - color = NearTheme.colors.WHITE_FFFFFF, - ) { + Row( + modifier = + Modifier.onNoRippleClick(onClick = { + onRelationChanged(Relation.FAMILY) + }), + verticalAlignment = Alignment.CenterVertically, + ) { + NearSmallRadioButton( + selected = friendProfileEditorUIState.relation == Relation.FAMILY, + onClick = {}, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.friend_profile_editor_relation_family), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + NearSmallRadioButton( + selected = friendProfileEditorUIState.relation == Relation.ACQUAINTANCE, + onClick = { onRelationChanged(Relation.ACQUAINTANCE) }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.friend_profile_editor_relation_acquaintance), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + } + } + } + Spacer(modifier = Modifier.height(33.dp)) Row( modifier = Modifier - .padding( - start = 16.dp, - end = 12.dp, - top = 14.dp, - bottom = 14.dp, - ).onNoRippleClick({ - birthdayDatePickerState.value = true - }), + .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - friendProfileEditorUIState.birthday.value ?: "날짜 선택", + "연락 주기", style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - Image( - painter = painterResource(id = R.drawable.ic_24_down), - contentDescription = null, + color = NearTheme.colors.GRAY01_888888, ) + Spacer(modifier = Modifier.width(35.dp)) + Surface( + modifier = + modifier + .weight(1f) + .onNoRippleClick({ + showBottomSheet.value = true + }), + shape = RoundedCornerShape(12.dp), + border = + BorderStroke( + width = 1.dp, + color = NearTheme.colors.GRAY03_EBEBEB, + ), + color = NearTheme.colors.WHITE_FFFFFF, + ) { + Row( + modifier = + Modifier.padding( + start = 16.dp, + end = 12.dp, + top = 14.dp, + bottom = 14.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = + stringResource(friendProfileEditorUIState.contactFrequency.reminderInterval.labelRes) + + "(${friendProfileEditorUIState.contactFrequency.dayOfWeek} 마다)", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + Image( + painter = painterResource(id = R.drawable.ic_24_down), + contentDescription = null, + ) + } + } } - } - } - Spacer(modifier = Modifier.height(32.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "기념일", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Text( - modifier = - Modifier.onNoRippleClick(onClick = { - onAddAnniversary() - }), - text = "추가하기", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLUE01_5AA2E9, - ) - } - Spacer(modifier = Modifier.height(16.dp)) - } - if (friendProfileEditorUIState.anniversaries.isNotEmpty()) { - LazyColumn( - modifier = Modifier.background(color = NearTheme.colors.BG02_F4F9FD), - contentPadding = - PaddingValues( - top = 20.dp, - bottom = 32.dp, - start = 24.dp, - end = 20.dp, - ), - verticalArrangement = Arrangement.spacedBy(32.dp), - ) { - items( - count = friendProfileEditorUIState.anniversaries.size, - ) { index -> - Column { - val anniversaryDatePickerState = remember { mutableStateOf(false) } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = + Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + val birthdayDatePickerState = remember { mutableStateOf(false) } val datePickerState = rememberDatePickerState() - if (anniversaryDatePickerState.value) { + if (birthdayDatePickerState.value) { NearDatePicker( datePickerState = datePickerState, - onDismiss = { anniversaryDatePickerState.value = false }, + onDismiss = { birthdayDatePickerState.value = false }, onDateSelected = { it?.let { - onAnniversaryDateSelected(index, it) + onBirthdayChanged(it) } }, ) } - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "기념일 이름", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(23.dp)) - NearTextField( - modifier = Modifier.weight(1f), - value = friendProfileEditorUIState.anniversaries[index].title.value, - onValueChange = { - onAnniversaryNameChange(index, it) - }, - ) - } - if (friendProfileEditorUIState.anniversaries[index].title.error) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - "이름을 입력해주세요.", - style = NearTheme.typography.FC_12_MEDIUM, - color = NearTheme.colors.NEGATIVE_F04E4E, - ) - } - Spacer(modifier = Modifier.height(16.dp)) - Row( + Text( + "생일", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(62.dp)) + Surface( modifier = - Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, + modifier + .weight(1f), + shape = RoundedCornerShape(12.dp), + border = + BorderStroke( + width = 1.dp, + color = NearTheme.colors.GRAY03_EBEBEB, + ), + color = NearTheme.colors.WHITE_FFFFFF, ) { - Text( - "날짜", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(62.dp)) - Surface( + Row( modifier = - modifier - .weight(1f) - .onNoRippleClick(onClick = { - anniversaryDatePickerState.value = true - }), - shape = RoundedCornerShape(12.dp), - border = - BorderStroke( - width = 1.dp, - color = NearTheme.colors.GRAY03_EBEBEB, - ), - color = NearTheme.colors.WHITE_FFFFFF, - ) { - Row( - modifier = - Modifier.padding( + Modifier + .padding( start = 16.dp, end = 12.dp, top = 14.dp, bottom = 14.dp, - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - friendProfileEditorUIState.anniversaries[index].date.value - ?: "날짜 선택", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - Image( - painter = painterResource(id = R.drawable.ic_24_down), - contentDescription = null, - ) - } + ).onNoRippleClick({ + birthdayDatePickerState.value = true + }), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + friendProfileEditorUIState.birthday.value ?: "날짜 선택", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + Image( + painter = painterResource(id = R.drawable.ic_24_down), + contentDescription = null, + ) } } - Spacer(modifier = Modifier.height(32.dp)) + } + Spacer(modifier = Modifier.height(32.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "기념일", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) Text( modifier = - Modifier - .fillMaxWidth() - .onNoRippleClick( - onClick = { - onRemoveAnniversary(index) - }, - ), - textAlign = TextAlign.End, - text = "삭제하기", - textDecoration = TextDecoration.Underline, + Modifier.onNoRippleClick(onClick = { + onAddAnniversary() + }), + text = "추가하기", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLUE01_5AA2E9, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + } + items( + count = friendProfileEditorUIState.anniversaries.size, + ) { index -> + Column( + modifier = + Modifier + .background(color = NearTheme.colors.BG02_F4F9FD) + .padding( + PaddingValues( + top = 20.dp, + bottom = 32.dp, + start = 24.dp, + end = 20.dp, + ), + ), + ) { + val anniversaryDatePickerState = remember { mutableStateOf(false) } + val datePickerState = + rememberDatePickerState() + if (anniversaryDatePickerState.value) { + NearDatePicker( + datePickerState = datePickerState, + onDismiss = { anniversaryDatePickerState.value = false }, + onDateSelected = { + it?.let { + onAnniversaryDateSelected(index, it) + } + }, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "기념일 이름", + style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.GRAY01_888888, ) + Spacer(modifier = Modifier.width(23.dp)) + NearTextField( + modifier = Modifier.weight(1f), + value = friendProfileEditorUIState.anniversaries[index].title.value, + onValueChange = { + onAnniversaryNameChange(index, it) + }, + ) + } + if (friendProfileEditorUIState.anniversaries[index].title.error) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + "이름을 입력해주세요.", + style = NearTheme.typography.FC_12_MEDIUM, + color = NearTheme.colors.NEGATIVE_F04E4E, + ) } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = + Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "날짜", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(62.dp)) + Surface( + modifier = + modifier + .weight(1f) + .onNoRippleClick(onClick = { + anniversaryDatePickerState.value = true + }), + shape = RoundedCornerShape(12.dp), + border = + BorderStroke( + width = 1.dp, + color = NearTheme.colors.GRAY03_EBEBEB, + ), + color = NearTheme.colors.WHITE_FFFFFF, + ) { + Row( + modifier = + Modifier.padding( + start = 16.dp, + end = 12.dp, + top = 14.dp, + bottom = 14.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + friendProfileEditorUIState.anniversaries[index].date.value + ?: "날짜 선택", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + Image( + painter = painterResource(id = R.drawable.ic_24_down), + contentDescription = null, + ) + } + } + } + Spacer(modifier = Modifier.height(32.dp)) + Text( + modifier = + Modifier + .fillMaxWidth() + .onNoRippleClick( + onClick = { + onRemoveAnniversary(index) + }, + ), + textAlign = TextAlign.End, + text = "삭제하기", + textDecoration = TextDecoration.Underline, + color = NearTheme.colors.GRAY01_888888, + ) } } } - Spacer(modifier = Modifier.height(24.dp)) - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), - ) { - Text( - modifier = Modifier.padding(top = 16.dp), - text = "메모", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(23.dp)) - NearLimitedTextField( + item { + Spacer(modifier = Modifier.height(24.dp)) + Row( modifier = Modifier - .weight(1f) - .height(180.dp), - value = friendProfileEditorUIState.memo.value ?: "", - onValueChange = { - onMemoChanged(it) - }, - placeHolderText = - "꼭 기억해야 할 내용을 기록해보세요.\n" + - "예) 날생선 X, 작년 생일에\n" + - "키링 선물함 등", - maxTextCount = 100, - ) + .fillMaxWidth() + .padding(horizontal = 24.dp), + ) { + Text( + modifier = Modifier.padding(top = 16.dp), + text = "메모", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(23.dp)) + NearLimitedTextField( + modifier = + Modifier + .weight(1f) + .height(180.dp), + value = friendProfileEditorUIState.memo.value ?: "", + onValueChange = { + onMemoChanged(it) + }, + placeHolderText = + "꼭 기억해야 할 내용을 기록해보세요.\n" + + "예) 날생선 X, 작년 생일에\n" + + "키링 선물함 등", + maxTextCount = 100, + ) + } + Spacer(modifier = Modifier.height(80.dp)) } - Spacer(modifier = Modifier.height(80.dp)) } } From acdd13ae3ac73a27a2087232878df3d90b8b7d42 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Mon, 1 Sep 2025 00:37:31 +0900 Subject: [PATCH 036/100] =?UTF-8?q?feat:=20=EC=88=98=EC=A0=95=EC=8B=9C=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=A1=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../friendprofile/FriendProfileScreen.kt | 4 +-- .../FriendProfileEditorScreen.kt | 30 ++++++++++++++++--- .../FriendProfileEditorViewModel.kt | 13 +++++--- .../uistate/FriendProfileEditorUIEvent.kt | 2 ++ .../presentation/feature/main/NearNavHost.kt | 1 + 5 files changed, 39 insertions(+), 11 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index 73de2748..3bfe7e7a 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -153,14 +153,12 @@ fun FriendProfileScreen( ) { when (friendState) { is FriendState.Success -> { - val scrollState = rememberScrollState() val friend = friendState.friend Column( modifier = Modifier .align(Alignment.TopStart) .fillMaxSize() - .verticalScroll(scrollState) .background(NearTheme.colors.WHITE_FFFFFF), ) { if (recordSuccessDialogState) { @@ -496,7 +494,7 @@ private fun RecordTab( } else { Spacer(modifier = Modifier.height(13.dp)) LazyVerticalGrid( - GridCells.Fixed(3), + columns = GridCells.Fixed(3), verticalArrangement = Arrangement.spacedBy(24.dp), contentPadding = PaddingValues(bottom = 60.dp), ) { diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index c78c0708..0a86ae91 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -1,6 +1,5 @@ package com.alarmy.near.presentation.feature.friendprofileedittor -import android.util.Log import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -47,6 +46,7 @@ import com.alarmy.near.model.Relation import com.alarmy.near.model.ReminderInterval import com.alarmy.near.presentation.feature.friendprofileedittor.component.NearDatePicker import com.alarmy.near.presentation.feature.friendprofileedittor.component.ReminderIntervalBottomSheet +import com.alarmy.near.presentation.feature.friendprofileedittor.dialog.EditorExitDialog import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIEvent import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIState import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar @@ -65,11 +65,18 @@ fun FriendProfileEditorRoute( onSuccessEdit: (Friend) -> Unit = {}, ) { val friendProfileEditorUIState = viewModel.uiState.collectAsStateWithLifecycle() + val warningDialogState = remember { mutableStateOf(false) } LaunchedEffect(viewModel.uiEvent) { launch { viewModel.uiEvent.collect { event -> when (event) { FriendProfileEditorUIEvent.WarningExit -> { + warningDialogState.value = true + } + + FriendProfileEditorUIEvent.Exit -> { + warningDialogState.value = false + onClickBackButton() } is FriendProfileEditorUIEvent.FriendProfileEditFailure -> { @@ -81,7 +88,6 @@ fun FriendProfileEditorRoute( } is FriendProfileEditorUIEvent.FriendProfileEditSuccess -> { - Log.d("FriendProfileEditorRoute", "FriendProfileEditSuccess") onSuccessEdit(event.friend) } } @@ -89,9 +95,10 @@ fun FriendProfileEditorRoute( } } FriendProfileEditorScreen( - onClickBackButton = onClickBackButton, - onNameChanged = viewModel::onNameChanged, friendProfileEditorUIState = friendProfileEditorUIState.value, + dialogState = warningDialogState.value, + onClickBackButton = viewModel::onExit, + onNameChanged = viewModel::onNameChanged, onRelationChanged = viewModel::onRelationChanged, onReminderIntervalChanged = viewModel::onRemindIntervalChanged, onBirthdayChanged = viewModel::onBirthdayChanged, @@ -101,6 +108,8 @@ fun FriendProfileEditorRoute( onAddAnniversary = viewModel::onAddAnniversary, onMemoChanged = viewModel::onMemoChanged, onSubmit = viewModel::onSubmit, + onEditorExit = onClickBackButton, + onCloseDialog = { warningDialogState.value = false }, ) } @@ -108,6 +117,7 @@ fun FriendProfileEditorRoute( @Composable fun FriendProfileEditorScreen( modifier: Modifier = Modifier, + dialogState: Boolean = false, friendProfileEditorUIState: FriendProfileEditorUIState, onClickBackButton: () -> Unit = {}, onNameChanged: (String) -> Unit = {}, @@ -120,6 +130,8 @@ fun FriendProfileEditorScreen( onAddAnniversary: () -> Unit = {}, onMemoChanged: (String) -> Unit = {}, onSubmit: () -> Unit = {}, + onEditorExit: () -> Unit = {}, + onCloseDialog: () -> Unit = {}, ) { val density = LocalDensity.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } @@ -132,6 +144,16 @@ fun FriendProfileEditorScreen( showBottomSheet.value = false }) } + if (dialogState) { + EditorExitDialog( + onDismissRequest = { + onCloseDialog() + }, + onConfirm = { + onEditorExit() + }, + ) + } LazyColumn( modifier = Modifier diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt index d0657435..51b7d801 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt @@ -163,10 +163,15 @@ class FriendProfileEditorViewModel } fun onExit() { - if (uiState.value.anniversaries.any { it.title.isDirty || it.date.isDirty } || - uiState.value.name.isDirty || uiState.value.memo.isDirty || - uiState.value.contactFrequency != friend.contactFrequency || uiState.value.birthday.isDirty - ) { + viewModelScope.launch { + if (uiState.value.anniversaries.any { it.title.isDirty || it.date.isDirty } || + uiState.value.name.isDirty || uiState.value.memo.isDirty || + uiState.value.birthday.isDirty + ) { + _uiEvent.send(FriendProfileEditorUIEvent.WarningExit) + } else { + _uiEvent.send(FriendProfileEditorUIEvent.Exit) + } } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIEvent.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIEvent.kt index a8ffe2c7..bf84abfa 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIEvent.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIEvent.kt @@ -14,4 +14,6 @@ sealed interface FriendProfileEditorUIEvent { data object FriendProfileEditNetworkError : FriendProfileEditorUIEvent data object WarningExit : FriendProfileEditorUIEvent + + data object Exit : FriendProfileEditorUIEvent } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt index a3f4e2e7..20ecc2ae 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 @@ -61,6 +61,7 @@ internal fun NearNavHost( ) }) friendProfileEditorNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = { + navController.popBackStack() }, onSuccessEdit = { navController.previousBackStackEntry?.savedStateHandle?.set( FRIEND_PROFILE_EDIT_COMPLETE_KEY, From 9819c4c790b4d254fc85abb6f6bb5fec5f5d4b29 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Mon, 1 Sep 2025 00:40:30 +0900 Subject: [PATCH 037/100] =?UTF-8?q?fix:=20=EC=B9=9C=EA=B5=AC=20=EA=B4=80?= =?UTF-8?q?=EA=B3=84=20=EB=B2=84=ED=8A=BC=20=ED=81=B4=EB=A6=AD=20=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FriendProfileEditorScreen.kt | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index 0a86ae91..7877f281 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -247,15 +247,13 @@ fun FriendProfileEditorScreen( horizontalArrangement = Arrangement.SpaceBetween, ) { Row( - modifier = - Modifier.onNoRippleClick(onClick = { - onRelationChanged(Relation.FRIEND) - }), verticalAlignment = Alignment.CenterVertically, ) { NearSmallRadioButton( selected = friendProfileEditorUIState.relation == Relation.FRIEND, - onClick = {}, + onClick = { + onRelationChanged(Relation.FRIEND) + }, ) Spacer(modifier = Modifier.width(8.dp)) Text( @@ -265,15 +263,13 @@ fun FriendProfileEditorScreen( ) } Row( - modifier = - Modifier.onNoRippleClick(onClick = { - onRelationChanged(Relation.FAMILY) - }), verticalAlignment = Alignment.CenterVertically, ) { NearSmallRadioButton( selected = friendProfileEditorUIState.relation == Relation.FAMILY, - onClick = {}, + onClick = { + onRelationChanged(Relation.FAMILY) + }, ) Spacer(modifier = Modifier.width(8.dp)) Text( From 79b504f6561b9da0d3ae7b8a8ecdcf1c8f6efd16 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Mon, 1 Sep 2025 00:50:44 +0900 Subject: [PATCH 038/100] =?UTF-8?q?fix:=20=EC=B1=94=EA=B9=80=EC=88=9C?= =?UTF-8?q?=EC=84=9C=20=EC=B5=9C=EC=8B=A0=EC=88=9C=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/friendprofile/FriendProfileScreen.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index 3bfe7e7a..f08e9661 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -24,9 +24,7 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -499,7 +497,11 @@ private fun RecordTab( contentPadding = PaddingValues(bottom = 60.dp), ) { items(friendShipRecordState.records.size) { - RecordItem(friendRecord = friendShipRecordState.records[it], index = it) + RecordItem( + friendRecord = friendShipRecordState.records[friendShipRecordState.records.size - 1 - it], + index = + friendShipRecordState.records.size - it, + ) } } } @@ -541,7 +543,7 @@ private fun RecordItem( contentDescription = null, ) Text( - "${index + 1}번째 챙김", + "${index}번째 챙김", style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLUE01_5AA2E9, ) From 0c088683cff363964ef157c5154761122ffdaf4f Mon Sep 17 00:00:00 2001 From: StopStone Date: Mon, 1 Sep 2025 20:41:16 +0900 Subject: [PATCH 039/100] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EB=B0=8F=20=EB=A7=8C=EB=A3=8C=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `AuthRepositoryImpl` - 소셜 로그인 시 만료 시간 계산 및 저장 로직 추가 - 토큰 갱신 재시도 로직 (`refreshTokenWithRetry`) 추가 - 401 에러 메시지 수정 - `TokenPreferences` - 토큰 만료 시간 (`expires_at`) 저장 및 조회 기능 추가 - 토큰 만료 여부 확인 (`isTokenExpired`) 기능 추가 - 액세스 토큰 관찰 (`observeAccessToken`) 기능 추가 - 기존 `saveAccessToken`, `saveRefreshToken` 메서드 삭제 - `TokenRefreshResponse`, `LoginResponse` - `refreshToken` 필드를 `refreshTokenInfo` (객체)로 변경하여 만료 시간 정보 포함 - `TokenInterceptor` - 자동 토큰 갱신 및 재시도 로직 구현 - 토큰 만료 시간 기반의 유효성 검사 로직 추가 - 토큰 변화를 관찰하여 실시간으로 `currentToken`, `tokenExpiresAt` 업데이트 - 인증 제외 경로를 `AuthEndpoint` enum으로 관리 - 토큰 갱신 실패 시 토큰 삭제 처리 --- .../data/local/datastore/TokenPreferences.kt | 51 ++-- .../data/repository/AuthRepositoryImpl.kt | 224 ++++++++++-------- .../near/network/auth/TokenInterceptor.kt | 185 ++++++++++++--- .../near/network/response/LoginResponse.kt | 12 +- .../network/response/TokenRefreshResponse.kt | 5 +- 5 files changed, 327 insertions(+), 150 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt b/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt index a0fdc6ac..4258377b 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt @@ -3,6 +3,7 @@ package com.alarmy.near.data.local.datastore import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -22,26 +23,7 @@ class TokenPreferences ) { private val accessTokenKey = stringPreferencesKey("access_token") private val refreshTokenKey = stringPreferencesKey("refresh_token") - - - - /** - * 액세스 토큰 저장 - */ - suspend fun saveAccessToken(token: String) { - dataStore.edit { preferences -> - preferences[accessTokenKey] = token - } - } - - /** - * 리프레시 토큰 저장 - */ - suspend fun saveRefreshToken(token: String) { - dataStore.edit { preferences -> - preferences[refreshTokenKey] = token - } - } + private val expiresAtKey = longPreferencesKey("expires_at") /** * 두 토큰 동시 저장 @@ -49,12 +31,17 @@ class TokenPreferences suspend fun saveTokens( accessToken: String, refreshToken: String?, + expiresIn: Long? = null, ) { dataStore.edit { preferences -> preferences[accessTokenKey] = accessToken refreshToken?.let { preferences[refreshTokenKey] = it } + expiresIn?.let { + val expiresAt = System.currentTimeMillis() + (it * 1000) + preferences[expiresAtKey] = expiresAt + } } } @@ -73,9 +60,22 @@ class TokenPreferences */ suspend fun hasValidTokens(): Boolean { val accessToken = getAccessToken() - return !accessToken.isNullOrBlank() + return !accessToken.isNullOrBlank() && !isTokenExpired() } + /** + * 토큰 만료 여부 확인 + */ + suspend fun isTokenExpired(): Boolean { + val expiresAt = dataStore.data.first()[expiresAtKey] ?: return true + return System.currentTimeMillis() >= expiresAt + } + + /** + * 토큰 만료 시간 조회 + */ + suspend fun getTokenExpiresAt(): Long? = dataStore.data.first()[expiresAtKey] + /** * 모든 토큰 삭제 */ @@ -83,10 +83,17 @@ class TokenPreferences dataStore.edit { preferences -> preferences.remove(accessTokenKey) preferences.remove(refreshTokenKey) + preferences.remove(expiresAtKey) } } - + /** + * 액세스 토큰 관찰 + */ + fun observeAccessToken() = + dataStore.data.map { preferences -> + preferences[accessTokenKey] + } /** * 로그인 상태 관찰 diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt index a2d568bc..8c51a03b 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt @@ -7,111 +7,121 @@ import com.alarmy.near.model.ProviderType import com.alarmy.near.network.request.SocialLoginRequest import com.alarmy.near.network.request.TokenRefreshRequest import com.alarmy.near.network.service.AuthService +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import retrofit2.HttpException +import java.text.SimpleDateFormat +import java.util.Locale import javax.inject.Inject class AuthRepositoryImpl - @Inject - constructor( - private val authService: AuthService, - private val socialLoginProcessor: SocialLoginProcessor, - private val tokenPreferences: TokenPreferences, - ) : AuthRepository { - override suspend fun performSocialLogin(providerType: ProviderType): LoginResult = - try { - val result = socialLoginProcessor.processLogin(providerType) - - if (result.isSuccess) { - val accessToken = result.getOrThrow() - socialLogin(accessToken, providerType) - } else { - val providerName = providerType.name.lowercase() - LoginResult( - isSuccess = false, - errorMessage = result.exceptionOrNull()?.message ?: "$providerName 로그인에 실패했습니다", - ) - } - } catch (exception: Exception) { +@Inject +constructor( + private val authService: AuthService, + private val socialLoginProcessor: SocialLoginProcessor, + private val tokenPreferences: TokenPreferences, +) : AuthRepository { + override suspend fun performSocialLogin(providerType: ProviderType): LoginResult = + try { + val result = socialLoginProcessor.processLogin(providerType) + + if (result.isSuccess) { + val accessToken = result.getOrThrow() + socialLogin(accessToken, providerType) + } else { val providerName = providerType.name.lowercase() LoginResult( isSuccess = false, - errorMessage = exception.message ?: "$providerName 로그인 중 오류가 발생했습니다", + errorMessage = result.exceptionOrNull()?.message ?: "$providerName 로그인에 실패했습니다", ) } + } catch (exception: Exception) { + val providerName = providerType.name.lowercase() + LoginResult( + isSuccess = false, + errorMessage = exception.message ?: "$providerName 로그인 중 오류가 발생했습니다", + ) + } - override suspend fun socialLogin( - accessToken: String, - providerType: ProviderType, - ): LoginResult = - try { - val request = - SocialLoginRequest( - accessToken = accessToken, - providerType = providerType.name, - ) - - val response = authService.socialLogin(request) - - // 토큰 저장 - tokenPreferences.saveTokens( - accessToken = response.accessToken, - refreshToken = response.refreshToken, - ) - - LoginResult( - isSuccess = true, - accessToken = response.accessToken, - refreshToken = response.refreshToken, - ) - } catch (exception: HttpException) { - val errorMessage = - when (exception.code()) { - 400 -> "잘못된 요청입니다" - 401 -> "인증에 실패했습니다" - 403 -> "접근이 거부되었습니다" - 500 -> "서버에 문제가 발생했습니다" - else -> "로그인 중 오류가 발생했습니다" - } + override suspend fun socialLogin( + accessToken: String, + providerType: ProviderType, + ): LoginResult = + try { + val request = SocialLoginRequest( + accessToken = accessToken, + providerType = providerType.name, + ) + + val response = authService.socialLogin(request) + + // 토큰 저장 + val expiresIn = calculateExpiresIn(response.refreshTokenInfo?.expiresAt) + + tokenPreferences.saveTokens( + accessToken = response.accessToken, + refreshToken = response.refreshTokenInfo?.token, + expiresIn = expiresIn, + ) + + LoginResult( + isSuccess = true, + accessToken = response.accessToken, + refreshToken = response.refreshTokenInfo?.token, + ) + } catch (exception: HttpException) { + val errorMessage = when (exception.code()) { + 400 -> "잘못된 요청입니다" + 401 -> "소셜 로그인에 실패했습니다. 다시 시도해주세요." + 403 -> "접근이 거부되었습니다" + 500 -> "서버에 문제가 발생했습니다" + else -> "로그인 중 오류가 발생했습니다" + } - LoginResult( - isSuccess = false, - errorMessage = errorMessage, - ) - } catch (exception: Exception) { - val errorMessage = exception.message ?: "알 수 없는 오류가 발생했습니다" + LoginResult( + isSuccess = false, + errorMessage = errorMessage, + ) + } catch (exception: Exception) { + val errorMessage = exception.message ?: "알 수 없는 오류가 발생했습니다" + + LoginResult( + isSuccess = false, + errorMessage = errorMessage, + ) + } - LoginResult( - isSuccess = false, - errorMessage = errorMessage, - ) - } + override suspend fun logout() { + try { + tokenPreferences.clearAllTokens() + } catch (exception: Exception) { + throw exception + } + } - override suspend fun logout() { - try { - tokenPreferences.clearAllTokens() - } catch (exception: Exception) { - throw exception - } + override suspend fun isLoggedIn(): Boolean = + try { + tokenPreferences.hasValidTokens() + } catch (exception: Exception) { + false } - override suspend fun isLoggedIn(): Boolean = - try { - tokenPreferences.hasValidTokens() - } catch (exception: Exception) { - false - } + override suspend fun getCurrentUserToken(): String? = + try { + tokenPreferences.getAccessToken() + } catch (exception: Exception) { + null + } - override suspend fun getCurrentUserToken(): String? = - try { - tokenPreferences.getAccessToken() - } catch (exception: Exception) { - null - } + override fun observeLoginStatus(): Flow = tokenPreferences.observeLoginStatus() - override fun observeLoginStatus(): Flow = tokenPreferences.observeLoginStatus() + override suspend fun refreshToken(): Boolean = refreshTokenWithRetry() - override suspend fun refreshToken(): Boolean = + /** + * 토큰 갱신 (재시도 로직 포함) + */ + private suspend fun refreshTokenWithRetry(maxRetries: Int = 3): Boolean { + repeat(maxRetries) { attempt -> try { val refreshToken = tokenPreferences.getRefreshToken() ?: return false @@ -120,22 +130,50 @@ class AuthRepositoryImpl val response = authService.renewToken(request) // 새로운 토큰 저장 + val expiresIn = calculateExpiresIn(response.refreshTokenInfo?.expiresAt) + tokenPreferences.saveTokens( accessToken = response.accessToken, - refreshToken = response.refreshToken, + refreshToken = response.refreshTokenInfo?.token, + expiresIn = expiresIn, ) - true + return true } catch (exception: Exception) { - // 토큰 갱신 실패 시 처리 when (exception) { is HttpException -> { - if (exception.code() == 401 || exception.code() == 403) { - // 리프레시 토큰도 만료된 경우 모든 토큰 삭제 - tokenPreferences.clearAllTokens() + when (exception.code()) { + 401, 403 -> { + // 리프레시 토큰도 만료된 경우 모든 토큰 삭제 + tokenPreferences.clearAllTokens() + return false // 재시도 불가 + } + + else -> return false + } + } + + else -> { + if (attempt < maxRetries - 1) { + delay(1000L * (attempt + 1)) + return@repeat } } } - false } + } + return false + } + + /** + * 만료 시간 계산 (초 단위) + */ + private fun calculateExpiresIn(expiresAtString: String?): Long? { + return expiresAtString?.let { expiresAt -> + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + val expiresAtTime = dateFormat.parse(expiresAt) + val currentTime = System.currentTimeMillis() + ((expiresAtTime?.time ?: currentTime) - currentTime) / 1000 // 초 단위로 변환 + } } +} diff --git a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt index 0ac0a574..a5af62d5 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt @@ -1,6 +1,11 @@ package com.alarmy.near.network.auth import com.alarmy.near.data.local.datastore.TokenPreferences +import com.alarmy.near.data.repository.AuthRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Request @@ -14,47 +19,163 @@ import javax.inject.Singleton */ @Singleton class TokenInterceptor - @Inject - constructor( - private val tokenPreferences: TokenPreferences, - ) : Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { - val originalRequest = chain.request() - - if (isAuthExcludedRequest(originalRequest)) { - return chain.proceed(originalRequest) +@Inject +constructor( + private val tokenPreferences: TokenPreferences, + private val authRepository: AuthRepository, +) : Interceptor { + + private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var currentToken: String? = null + private var tokenExpiresAt: Long? = null + private var isRefreshing = false + + init { + observeTokenChanges() + } + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + if (isAuthExcludedRequest(originalRequest)) { + return chain.proceed(originalRequest) + } + + // 현재 토큰 사용 + val validToken = getValidToken() + val requestWithAuth = if (validToken != null) { + originalRequest + .newBuilder() + .header("Authorization", "Bearer $validToken") + .build() + } else { + originalRequest + } + + // 첫 번째 요청 시도 + val response = chain.proceed(requestWithAuth) + + // 401 에러인 경우 자동 토큰 갱신 시도 + if (response.code == 401 && validToken != null) { + val refreshed = attemptTokenRefresh() + if (refreshed) { + // 토큰 갱신 성공 시 원래 요청 재시도 + val newToken = getValidToken() + if (newToken != null) { + val retryRequest = originalRequest + .newBuilder() + .header("Authorization", "Bearer $newToken") + .build() + return chain.proceed(retryRequest) + } + } else { + // 토큰 갱신 실패 시 토큰 삭제 + handleTokenExpired() } + } - val requestWithAuth = - runBlocking { - addAuthHeader(originalRequest) + return response + } + + /** + * 토큰 변화 관찰 + */ + private fun observeTokenChanges() { + coroutineScope.launch { + try { + // 초기 토큰 로드 + currentToken = tokenPreferences.getAccessToken() + tokenExpiresAt = tokenPreferences.getTokenExpiresAt() + + // 토큰 변화 실시간 관찰 + tokenPreferences.observeAccessToken().collect { token -> + currentToken = token + // 토큰이 변경되면 만료 시간도 다시 로드 + if (token != null) { + tokenExpiresAt = tokenPreferences.getTokenExpiresAt() + } else { + tokenExpiresAt = null + } } + } catch (e: Exception) { + currentToken = null + tokenExpiresAt = null + } + } + } + + /** + * 유효한 토큰 반환 (만료 검사 포함) + */ + private fun getValidToken(): String? { + val token = currentToken + val expiresAt = tokenExpiresAt + + return if (token != null && !isTokenExpired(expiresAt)) { + token + } else { + null + } + } + + /** + * 토큰 만료 검사 + */ + private fun isTokenExpired(expiresAt: Long?): Boolean { + if (expiresAt == null) return true + return System.currentTimeMillis() >= expiresAt + } - return chain.proceed(requestWithAuth) + /** + * 자동 토큰 갱신 시도 (빅테크 수준) + */ + private fun attemptTokenRefresh(): Boolean { + if (isRefreshing) { + return false // 이미 갱신 중이면 대기 } - /** - * 인증이 필요없는 요청인지 확인 - */ - private fun isAuthExcludedRequest(request: Request): Boolean { - val url = request.url.toString() - return url.contains("/auth/social") || - url.contains("/auth/renew") + return runBlocking { + try { + isRefreshing = true + authRepository.refreshToken() + } catch (e: Exception) { + false + } finally { + isRefreshing = false + } + } + } + + /** + * 토큰 만료 처리 + */ + private fun handleTokenExpired() { + currentToken = null + tokenExpiresAt = null + coroutineScope.launch { + tokenPreferences.clearAllTokens() } + } + + /** + * 인증이 필요없는 요청인지 확인 + */ + private fun isAuthExcludedRequest(request: Request): Boolean { + val url = request.url.toString() + return AuthEndpoint.EXCLUDED_PATHS.any { url.contains(it) } + } - /** - * Authorization 헤더 추가 - */ - private suspend fun addAuthHeader(originalRequest: Request): Request { - val accessToken = tokenPreferences.getAccessToken() + companion object { + enum class AuthEndpoint(val path: String) { + SOCIAL_LOGIN("/auth/social"), + TOKEN_RENEW("/auth/renew"); - return if (accessToken != null) { - originalRequest - .newBuilder() - .header("Authorization", "Bearer $accessToken") - .build() - } else { - originalRequest + companion object { + val EXCLUDED_PATHS = listOf( + SOCIAL_LOGIN.path, + TOKEN_RENEW.path, + ) } } } +} diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/LoginResponse.kt b/Near/app/src/main/java/com/alarmy/near/network/response/LoginResponse.kt index 3b77dfdc..c8ff5fd4 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/response/LoginResponse.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/response/LoginResponse.kt @@ -8,6 +8,14 @@ import kotlinx.serialization.Serializable data class LoginResponse( @SerialName("accessToken") val accessToken: String, - @SerialName("refreshToken") - val refreshToken: String? = null, + @SerialName("refreshTokenInfo") + val refreshTokenInfo: RefreshTokenInfo? = null, +) + +@Serializable +data class RefreshTokenInfo( + @SerialName("token") + val token: String, + @SerialName("expiresAt") + val expiresAt: String, // "2025-08-20 03:02:07" 형식 ) diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/TokenRefreshResponse.kt b/Near/app/src/main/java/com/alarmy/near/network/response/TokenRefreshResponse.kt index 04806840..b4b0dc73 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/response/TokenRefreshResponse.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/response/TokenRefreshResponse.kt @@ -1,5 +1,6 @@ package com.alarmy.near.network.response +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** @@ -7,6 +8,8 @@ import kotlinx.serialization.Serializable */ @Serializable data class TokenRefreshResponse( + @SerialName("accessToken") val accessToken: String, - val refreshToken: String? = null + @SerialName("refreshTokenInfo") + val refreshTokenInfo: RefreshTokenInfo? = null, ) From 737b9c3149fea65c5e92d91eff098ee63df5cfcb Mon Sep 17 00:00:00 2001 From: StopStone Date: Mon, 1 Sep 2025 21:01:13 +0900 Subject: [PATCH 040/100] =?UTF-8?q?refactor:=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20=EB=A1=9C=EC=A7=81=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../near/network/auth/TokenInterceptor.kt | 43 ++----------------- 1 file changed, 3 insertions(+), 40 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt index a5af62d5..6e4f520e 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt @@ -1,12 +1,10 @@ package com.alarmy.near.network.auth import com.alarmy.near.data.local.datastore.TokenPreferences -import com.alarmy.near.data.repository.AuthRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response @@ -22,13 +20,12 @@ class TokenInterceptor @Inject constructor( private val tokenPreferences: TokenPreferences, - private val authRepository: AuthRepository, ) : Interceptor { private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var currentToken: String? = null private var tokenExpiresAt: Long? = null - private var isRefreshing = false + init { observeTokenChanges() @@ -55,23 +52,9 @@ constructor( // 첫 번째 요청 시도 val response = chain.proceed(requestWithAuth) - // 401 에러인 경우 자동 토큰 갱신 시도 + // 401 에러인 경우 토큰 삭제 if (response.code == 401 && validToken != null) { - val refreshed = attemptTokenRefresh() - if (refreshed) { - // 토큰 갱신 성공 시 원래 요청 재시도 - val newToken = getValidToken() - if (newToken != null) { - val retryRequest = originalRequest - .newBuilder() - .header("Authorization", "Bearer $newToken") - .build() - return chain.proceed(retryRequest) - } - } else { - // 토큰 갱신 실패 시 토큰 삭제 - handleTokenExpired() - } + handleTokenExpired() } return response @@ -126,26 +109,6 @@ constructor( return System.currentTimeMillis() >= expiresAt } - /** - * 자동 토큰 갱신 시도 (빅테크 수준) - */ - private fun attemptTokenRefresh(): Boolean { - if (isRefreshing) { - return false // 이미 갱신 중이면 대기 - } - - return runBlocking { - try { - isRefreshing = true - authRepository.refreshToken() - } catch (e: Exception) { - false - } finally { - isRefreshing = false - } - } - } - /** * 토큰 만료 처리 */ From 9f03d407203a27af16952ea8afaf97f167fb9400 Mon Sep 17 00:00:00 2001 From: StopStone Date: Mon, 1 Sep 2025 22:17:59 +0900 Subject: [PATCH 041/100] =?UTF-8?q?refactor:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=EB=B0=B1=EA=B7=B8=EB=9D=BC=EC=9A=B4=EB=93=9C=20=EC=83=89?= =?UTF-8?q?=EC=83=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/alarmy/near/presentation/feature/login/LoginScreen.kt | 2 ++ 1 file changed, 2 insertions(+) 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 3a4691f6..10990fba 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 @@ -1,6 +1,7 @@ package com.alarmy.near.presentation.feature.login import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column @@ -59,6 +60,7 @@ fun LoginScreen( modifier = Modifier .fillMaxSize() + .background(NearTheme.colors.WHITE_FFFFFF) .systemBarsPadding(), ) { LoginIntroductionSection(modifier = Modifier.fillMaxWidth()) From 123091c8bd096aea0af720a61485fb8939763ae4 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Mon, 1 Sep 2025 22:23:28 +0900 Subject: [PATCH 042/100] =?UTF-8?q?refactor:=20string=20res=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../friendprofile/FriendProfileScreen.kt | 10 +- .../FriendProfileEditorScreen.kt | 502 +++++++++--------- .../FriendProfileEditorViewModel.kt | 2 +- Near/app/src/main/res/values/strings.xml | 20 +- 4 files changed, 277 insertions(+), 257 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index f08e9661..16adc03f 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -185,7 +185,7 @@ fun FriendProfileScreen( ) Spacer(modifier = Modifier.height(8.dp)) Text( - "더 가까워졌어요!", + stringResource(R.string.friend_profile_info_contact_success_text), style = NearTheme.typography.B1_16_BOLD, color = Color(0xff222222), ) @@ -219,7 +219,7 @@ fun FriendProfileScreen( }, text = { Text( - "수정", + stringResource(R.string.friend_profile_info_edit), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLACK_1A1A1A, ) @@ -232,7 +232,7 @@ fun FriendProfileScreen( }, text = { Text( - "삭제", + stringResource(R.string.friend_profile_info_delete), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLACK_1A1A1A, ) @@ -484,7 +484,7 @@ private fun RecordTab( Image(painterResource(R.drawable.img_100_character_empty), contentDescription = null) Spacer(modifier = Modifier.height(16.dp)) Text( - "이번달은 챙길 사람이 없네요.", + stringResource(R.string.friend_profile_info_empty_contact_friend), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.GRAY01_888888, ) @@ -543,7 +543,7 @@ private fun RecordItem( contentDescription = null, ) Text( - "${index}번째 챙김", + stringResource(R.string.friend_profile_info_contact_record_text, index), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLUE01_5AA2E9, ) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index 7877f281..f7a03878 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -160,279 +160,283 @@ fun FriendProfileEditorScreen( .fillMaxSize() .background(NearTheme.colors.WHITE_FFFFFF), ) { - if (friendProfileEditorUIState.anniversaries.isNotEmpty()) { - item { - Spacer(modifier = Modifier.padding(top = statusBarHeightDp)) - NearTopAppbar( - modifier = Modifier.padding(end = 24.dp), - title = "", - onClickBackButton = onClickBackButton, - menuButton = { - Text( - modifier = - Modifier.onNoRippleClick(onClick = { - onSubmit() - }), - text = "완료", - style = NearTheme.typography.B1_16_BOLD, - color = NearTheme.colors.BLACK_1A1A1A, - ) - }, - ) - Spacer(modifier = Modifier.height(16.dp)) - Column( + item { + Spacer(modifier = Modifier.padding(top = statusBarHeightDp)) + NearTopAppbar( + modifier = Modifier.padding(end = 24.dp), + title = "", + onClickBackButton = onClickBackButton, + menuButton = { + Text( + modifier = + Modifier.onNoRippleClick(onClick = { + onSubmit() + }), + text = "완료", + style = NearTheme.typography.B1_16_BOLD, + color = NearTheme.colors.BLACK_1A1A1A, + ) + }, + ) + Spacer(modifier = Modifier.height(16.dp)) + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 20.dp), + ) { + Row( modifier = Modifier - .fillMaxWidth() - .padding(start = 24.dp, end = 20.dp), + .fillMaxWidth(), ) { + Text( + modifier = Modifier.padding(top = 16.dp), + text = + buildAnnotatedString { + append("이름") + withStyle( + style = + SpanStyle( + color = NearTheme.colors.BLUE01_5AA2E9, + ), + ) { + append("*") + } + }, + textAlign = TextAlign.Center, + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(55.dp)) + NearTextField( + modifier = Modifier.weight(1f), + value = friendProfileEditorUIState.name.value, + onValueChange = { + onNameChanged(it) + }, + ) + } + if (friendProfileEditorUIState.name.error) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + "이름을 입력해주세요.", + style = NearTheme.typography.FC_12_MEDIUM, + color = NearTheme.colors.NEGATIVE_F04E4E, + ) + } + Spacer(modifier = Modifier.height(32.dp)) + Row( + modifier = + Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "관계", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(72.dp)) Row( modifier = Modifier - .fillMaxWidth(), + .weight(1f) + .padding(end = 35.dp), + horizontalArrangement = Arrangement.SpaceBetween, ) { - Text( - modifier = Modifier.padding(top = 16.dp), - text = - buildAnnotatedString { - append("이름") - withStyle( - style = - SpanStyle( - color = NearTheme.colors.BLUE01_5AA2E9, - ), - ) { - append("*") - } + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + NearSmallRadioButton( + selected = friendProfileEditorUIState.relation == Relation.FRIEND, + onClick = { + onRelationChanged(Relation.FRIEND) }, - textAlign = TextAlign.Center, - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(55.dp)) - NearTextField( - modifier = Modifier.weight(1f), - value = friendProfileEditorUIState.name.value, - onValueChange = { - onNameChanged(it) - }, - ) - } - if (friendProfileEditorUIState.name.error) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - "이름을 입력해주세요.", - style = NearTheme.typography.FC_12_MEDIUM, - color = NearTheme.colors.NEGATIVE_F04E4E, - ) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.friend_profile_editor_relation_friend), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + NearSmallRadioButton( + selected = friendProfileEditorUIState.relation == Relation.FAMILY, + onClick = { + onRelationChanged(Relation.FAMILY) + }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.friend_profile_editor_relation_family), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + NearSmallRadioButton( + selected = friendProfileEditorUIState.relation == Relation.ACQUAINTANCE, + onClick = { onRelationChanged(Relation.ACQUAINTANCE) }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.friend_profile_editor_relation_acquaintance), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + } } - Spacer(modifier = Modifier.height(32.dp)) - Row( + } + Spacer(modifier = Modifier.height(33.dp)) + Row( + modifier = + Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.friend_profile_editor_contact_period), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(35.dp)) + Surface( modifier = - Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, + modifier + .weight(1f) + .onNoRippleClick({ + showBottomSheet.value = true + }), + shape = RoundedCornerShape(12.dp), + border = + BorderStroke( + width = 1.dp, + color = NearTheme.colors.GRAY03_EBEBEB, + ), + color = NearTheme.colors.WHITE_FFFFFF, ) { - Text( - "관계", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(72.dp)) Row( modifier = - Modifier - .weight(1f) - .padding(end = 35.dp), + Modifier.padding( + start = 16.dp, + end = 12.dp, + top = 14.dp, + bottom = 14.dp, + ), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - NearSmallRadioButton( - selected = friendProfileEditorUIState.relation == Relation.FRIEND, - onClick = { - onRelationChanged(Relation.FRIEND) - }, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.friend_profile_editor_relation_freind), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - NearSmallRadioButton( - selected = friendProfileEditorUIState.relation == Relation.FAMILY, - onClick = { - onRelationChanged(Relation.FAMILY) - }, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.friend_profile_editor_relation_family), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - } - Row(verticalAlignment = Alignment.CenterVertically) { - NearSmallRadioButton( - selected = friendProfileEditorUIState.relation == Relation.ACQUAINTANCE, - onClick = { onRelationChanged(Relation.ACQUAINTANCE) }, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.friend_profile_editor_relation_acquaintance), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - } + Text( + text = + stringResource(friendProfileEditorUIState.contactFrequency.reminderInterval.labelRes) + + stringResource( + R.string.friend_profile_editor_contact_period_format, + friendProfileEditorUIState.contactFrequency.dayOfWeek, + ), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + Image( + painter = painterResource(id = R.drawable.ic_24_down), + contentDescription = null, + ) } } - Spacer(modifier = Modifier.height(33.dp)) - Row( + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = + Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + val birthdayDatePickerState = remember { mutableStateOf(false) } + val datePickerState = + rememberDatePickerState() + if (birthdayDatePickerState.value) { + NearDatePicker( + datePickerState = datePickerState, + onDismiss = { birthdayDatePickerState.value = false }, + onDateSelected = { + it?.let { + onBirthdayChanged(it) + } + }, + ) + } + Text( + stringResource(R.string.friend_profile_editor_birthday), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(62.dp)) + Surface( modifier = - Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, + modifier + .weight(1f), + shape = RoundedCornerShape(12.dp), + border = + BorderStroke( + width = 1.dp, + color = NearTheme.colors.GRAY03_EBEBEB, + ), + color = NearTheme.colors.WHITE_FFFFFF, ) { - Text( - "연락 주기", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(35.dp)) - Surface( + Row( modifier = - modifier - .weight(1f) - .onNoRippleClick({ - showBottomSheet.value = true - }), - shape = RoundedCornerShape(12.dp), - border = - BorderStroke( - width = 1.dp, - color = NearTheme.colors.GRAY03_EBEBEB, - ), - color = NearTheme.colors.WHITE_FFFFFF, - ) { - Row( - modifier = - Modifier.padding( + Modifier + .padding( start = 16.dp, end = 12.dp, top = 14.dp, bottom = 14.dp, - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = - stringResource(friendProfileEditorUIState.contactFrequency.reminderInterval.labelRes) + - "(${friendProfileEditorUIState.contactFrequency.dayOfWeek} 마다)", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - Image( - painter = painterResource(id = R.drawable.ic_24_down), - contentDescription = null, - ) - } - } - } - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = - Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - val birthdayDatePickerState = remember { mutableStateOf(false) } - val datePickerState = - rememberDatePickerState() - if (birthdayDatePickerState.value) { - NearDatePicker( - datePickerState = datePickerState, - onDismiss = { birthdayDatePickerState.value = false }, - onDateSelected = { - it?.let { - onBirthdayChanged(it) - } - }, - ) - } - Text( - "생일", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(62.dp)) - Surface( - modifier = - modifier - .weight(1f), - shape = RoundedCornerShape(12.dp), - border = - BorderStroke( - width = 1.dp, - color = NearTheme.colors.GRAY03_EBEBEB, - ), - color = NearTheme.colors.WHITE_FFFFFF, + ) + .onNoRippleClick({ + birthdayDatePickerState.value = true + }), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - Row( - modifier = - Modifier - .padding( - start = 16.dp, - end = 12.dp, - top = 14.dp, - bottom = 14.dp, - ).onNoRippleClick({ - birthdayDatePickerState.value = true - }), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - friendProfileEditorUIState.birthday.value ?: "날짜 선택", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - Image( - painter = painterResource(id = R.drawable.ic_24_down), - contentDescription = null, - ) - } + Text( + friendProfileEditorUIState.birthday.value ?: stringResource(R.string.friend_profile_editor_select_date), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + Image( + painter = painterResource(id = R.drawable.ic_24_down), + contentDescription = null, + ) } } - Spacer(modifier = Modifier.height(32.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "기념일", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Text( - modifier = - Modifier.onNoRippleClick(onClick = { - onAddAnniversary() - }), - text = "추가하기", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLUE01_5AA2E9, - ) - } - Spacer(modifier = Modifier.height(16.dp)) } + Spacer(modifier = Modifier.height(32.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.friend_profile_editor_anniversary), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Text( + modifier = + Modifier.onNoRippleClick(onClick = { + onAddAnniversary() + }), + text = stringResource(R.string.friend_profile_editor_anniversary_add), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLUE01_5AA2E9, + ) + } + Spacer(modifier = Modifier.height(16.dp)) } + } + if (friendProfileEditorUIState.anniversaries.isNotEmpty()) { items( count = friendProfileEditorUIState.anniversaries.size, ) { index -> @@ -465,7 +469,7 @@ fun FriendProfileEditorScreen( } Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = "기념일 이름", + text = stringResource(R.string.friend_profile_editor_anniversary_name), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.GRAY01_888888, ) @@ -481,7 +485,7 @@ fun FriendProfileEditorScreen( if (friendProfileEditorUIState.anniversaries[index].title.error) { Spacer(modifier = Modifier.height(8.dp)) Text( - "이름을 입력해주세요.", + stringResource(R.string.friend_profile_editor_name_hint_text), style = NearTheme.typography.FC_12_MEDIUM, color = NearTheme.colors.NEGATIVE_F04E4E, ) @@ -549,7 +553,7 @@ fun FriendProfileEditorScreen( }, ), textAlign = TextAlign.End, - text = "삭제하기", + text = stringResource(R.string.friend_profile_editor_delete), textDecoration = TextDecoration.Underline, color = NearTheme.colors.GRAY01_888888, ) @@ -581,13 +585,13 @@ fun FriendProfileEditorScreen( onMemoChanged(it) }, placeHolderText = - "꼭 기억해야 할 내용을 기록해보세요.\n" + + stringResource(R.string.friend_profile_editor_memo_default_text) + "예) 날생선 X, 작년 생일에\n" + "키링 선물함 등", maxTextCount = 100, ) + Spacer(modifier = Modifier.height(80.dp)) } - Spacer(modifier = Modifier.height(80.dp)) } } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt index 51b7d801..8892075e 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt @@ -177,7 +177,7 @@ class FriendProfileEditorViewModel fun onSubmit() { val updatedFriend = _uiState.value - if ((updatedFriend.name.error || updatedFriend.anniversaries.any { it.title.error })) { + if ((updatedFriend.name.error || updatedFriend.anniversaries.any { it.title.error || it.title.value.isBlank() })) { // error return } diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml index e40a3007..02884bc5 100644 --- a/Near/app/src/main/res/values/strings.xml +++ b/Near/app/src/main/res/values/strings.xml @@ -30,7 +30,8 @@ 기념일 메모 꼭 기억해야 할 내용을 기록해보세요.\n예) 날생선 X, 작년 생일에 키링 선물함 등 - 친구 + %1$s 더 가까워졌어요 + 친구 가족 지인 @@ -44,5 +45,20 @@ 친구 가족 지인 - %1$s 더 가까워졌어요 + 연락 주기 + 생일 + 날짜 선택 + 삭제하기 + 꼭 기억해야 할 내용을 기록해보세요.\n + 이름을 입력해주세요. + 기념일 이름 + 추가하기 + 기념일 + (%1$s 마다) + 더 가까워졌어요! + 수정 + 삭제 + 이번달은 챙길 사람이 없네요. + %1$d번째 챙김 + From 0f844a88080b08790dcb66aedea17d63ee595fd5 Mon Sep 17 00:00:00 2001 From: StopStone Date: Mon, 1 Sep 2025 22:23:48 +0900 Subject: [PATCH 043/100] =?UTF-8?q?chore:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=ED=8B=B0=EB=B8=8C=ED=82=A4=20CI=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/android-pull-request-ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/android-pull-request-ci.yml b/.github/workflows/android-pull-request-ci.yml index 5bb75fef..8854d504 100644 --- a/.github/workflows/android-pull-request-ci.yml +++ b/.github/workflows/android-pull-request-ci.yml @@ -43,6 +43,12 @@ jobs: run: | echo "TEMP_TOKEN=\"TEMP_TOKEN\"" >> local.properties + - name: Access Kakao KAKAO_NATIVE_APP_KEY + env: + KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }} + run: | + echo "KAKAO_NATIVE_APP_KEY=\"$KAKAO_NATIVE_APP_KEY\"" >> local.properties + - name: Grant execute permission for gradlew run: chmod +x gradlew From 877388d40aec0f2adb6871dc15555b958331a2a1 Mon Sep 17 00:00:00 2001 From: StopStone Date: Mon, 1 Sep 2025 22:28:42 +0900 Subject: [PATCH 044/100] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `TokenAuthenticator`를 구현하여 401 에러 발생 시 토큰 갱신 및 재요청 로직 추가 - `TokenInterceptor`에서 토큰 만료 처리 로직 제거 (Authenticator에서 처리) - `OkHttpClient`에 `TokenAuthenticator` 적용 --- .../near/network/auth/TokenAuthenticator.kt | 56 +++++++++++++++++++ .../near/network/auth/TokenInterceptor.kt | 22 +------- .../alarmy/near/network/di/NetworkModule.kt | 3 + 3 files changed, 62 insertions(+), 19 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/network/auth/TokenAuthenticator.kt diff --git a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenAuthenticator.kt b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenAuthenticator.kt new file mode 100644 index 00000000..81b9077e --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenAuthenticator.kt @@ -0,0 +1,56 @@ +package com.alarmy.near.network.auth + +import com.alarmy.near.data.repository.AuthRepository +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 토큰 인증자 + * 401 에러 발생 시 토큰 갱신을 시도하고 원래 요청을 재시도 + */ +@Singleton +class TokenAuthenticator +@Inject +constructor( + private val authRepository: AuthRepository, +) : Authenticator { + + override fun authenticate(route: Route?, response: Response): Request? { + // 401 에러가 아니면 null 반환 (인증 시도하지 않음) + if (response.code != 401) { + return null + } + + // 토큰 갱신 시도 + val refreshSuccess = try { + authRepository.refreshToken() + } catch (e: Exception) { + false + } + + // 토큰 갱신 실패 시 null 반환 (재로그인 필요) + if (!refreshSuccess) { + return null + } + + // 새로운 토큰으로 원래 요청 재시도 + val newToken = try { + authRepository.getCurrentUserToken() + } catch (e: Exception) { + return null + } + + return if (newToken != null) { + response.request + .newBuilder() + .header("Authorization", "Bearer $newToken") + .build() + } else { + null + } + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt index 6e4f520e..e821ce7c 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt @@ -49,15 +49,8 @@ constructor( originalRequest } - // 첫 번째 요청 시도 - val response = chain.proceed(requestWithAuth) - - // 401 에러인 경우 토큰 삭제 - if (response.code == 401 && validToken != null) { - handleTokenExpired() - } - - return response + // 요청 실행 (401 에러는 Authenticator에서 처리) + return chain.proceed(requestWithAuth) } /** @@ -109,16 +102,7 @@ constructor( return System.currentTimeMillis() >= expiresAt } - /** - * 토큰 만료 처리 - */ - private fun handleTokenExpired() { - currentToken = null - tokenExpiresAt = null - coroutineScope.launch { - tokenPreferences.clearAllTokens() - } - } + /** * 인증이 필요없는 요청인지 확인 diff --git a/Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt b/Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt index de4e1e8d..e6663ac9 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt @@ -1,6 +1,7 @@ package com.alarmy.near.network.di import com.alarmy.near.BuildConfig +import com.alarmy.near.network.auth.TokenAuthenticator import com.alarmy.near.network.auth.TokenInterceptor import dagger.Module import dagger.Provides @@ -32,11 +33,13 @@ object NetworkModule { fun provideOkHttpClient( loggingInterceptor: HttpLoggingInterceptor, tokenInterceptor: TokenInterceptor, + tokenAuthenticator: TokenAuthenticator, ): OkHttpClient = OkHttpClient .Builder() .addInterceptor(loggingInterceptor) .addInterceptor(tokenInterceptor) + .authenticator(tokenAuthenticator) .build() @Provides From 131cb47dc953d6b6de691983f6eacdcff51b90a3 Mon Sep 17 00:00:00 2001 From: StopStone Date: Mon, 1 Sep 2025 23:13:56 +0900 Subject: [PATCH 045/100] =?UTF-8?q?feat:=20TokenManager=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `TokenManager` 클래스를 추가하여 토큰 저장, 조회, 갱신 로직을 중앙에서 관리합니다. - `TokenAuthenticator`, `AuthRepositoryImpl`, `TokenInterceptor`에서 `TokenManager`를 사용하도록 수정하여 토큰 관리 책임을 위임합니다. - `AuthRepositoryImpl`에서 중복된 토큰 관련 로직 (만료 시간 계산 등)을 제거하고 `TokenManager`의 기능을 사용합니다. - `TokenAuthenticator`에서 `runBlocking`을 사용하여 `suspend` 함수를 호출하도록 수정했습니다. - `TokenInterceptor`에서 `runBlocking`을 사용하여 토큰을 가져오고, 토큰 변화를 관찰하는 로직을 제거하여 `TokenManager`에 의존하도록 단순화했습니다. --- .../data/repository/AuthRepositoryImpl.kt | 83 ++---------- .../near/network/auth/TokenAuthenticator.kt | 49 ++++---- .../near/network/auth/TokenInterceptor.kt | 78 ++---------- .../alarmy/near/network/auth/TokenManager.kt | 118 ++++++++++++++++++ 4 files changed, 158 insertions(+), 170 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/network/auth/TokenManager.kt diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt index 8c51a03b..cd1252d6 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt @@ -1,17 +1,13 @@ package com.alarmy.near.data.repository import com.alarmy.near.data.datasource.SocialLoginProcessor -import com.alarmy.near.data.local.datastore.TokenPreferences import com.alarmy.near.model.LoginResult import com.alarmy.near.model.ProviderType +import com.alarmy.near.network.auth.TokenManager import com.alarmy.near.network.request.SocialLoginRequest -import com.alarmy.near.network.request.TokenRefreshRequest import com.alarmy.near.network.service.AuthService -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import retrofit2.HttpException -import java.text.SimpleDateFormat -import java.util.Locale import javax.inject.Inject class AuthRepositoryImpl @@ -19,7 +15,7 @@ class AuthRepositoryImpl constructor( private val authService: AuthService, private val socialLoginProcessor: SocialLoginProcessor, - private val tokenPreferences: TokenPreferences, + private val tokenManager: TokenManager, ) : AuthRepository { override suspend fun performSocialLogin(providerType: ProviderType): LoginResult = try { @@ -56,9 +52,8 @@ constructor( val response = authService.socialLogin(request) // 토큰 저장 - val expiresIn = calculateExpiresIn(response.refreshTokenInfo?.expiresAt) - - tokenPreferences.saveTokens( + val expiresIn = tokenManager.calculateExpiresIn(response.refreshTokenInfo?.expiresAt) + tokenManager.saveTokens( accessToken = response.accessToken, refreshToken = response.refreshTokenInfo?.token, expiresIn = expiresIn, @@ -93,7 +88,7 @@ constructor( override suspend fun logout() { try { - tokenPreferences.clearAllTokens() + tokenManager.clearAllTokens() } catch (exception: Exception) { throw exception } @@ -101,79 +96,19 @@ constructor( override suspend fun isLoggedIn(): Boolean = try { - tokenPreferences.hasValidTokens() + tokenManager.hasValidToken() } catch (exception: Exception) { false } override suspend fun getCurrentUserToken(): String? = try { - tokenPreferences.getAccessToken() + tokenManager.getAccessToken() } catch (exception: Exception) { null } - override fun observeLoginStatus(): Flow = tokenPreferences.observeLoginStatus() - - override suspend fun refreshToken(): Boolean = refreshTokenWithRetry() - - /** - * 토큰 갱신 (재시도 로직 포함) - */ - private suspend fun refreshTokenWithRetry(maxRetries: Int = 3): Boolean { - repeat(maxRetries) { attempt -> - try { - val refreshToken = tokenPreferences.getRefreshToken() ?: return false - - // 토큰 갱신 API 호출 - val request = TokenRefreshRequest(refreshToken = refreshToken) - val response = authService.renewToken(request) - - // 새로운 토큰 저장 - val expiresIn = calculateExpiresIn(response.refreshTokenInfo?.expiresAt) - - tokenPreferences.saveTokens( - accessToken = response.accessToken, - refreshToken = response.refreshTokenInfo?.token, - expiresIn = expiresIn, - ) - - return true - } catch (exception: Exception) { - when (exception) { - is HttpException -> { - when (exception.code()) { - 401, 403 -> { - // 리프레시 토큰도 만료된 경우 모든 토큰 삭제 - tokenPreferences.clearAllTokens() - return false // 재시도 불가 - } + override fun observeLoginStatus(): Flow = tokenManager.observeLoginStatus() - else -> return false - } - } - - else -> { - if (attempt < maxRetries - 1) { - delay(1000L * (attempt + 1)) - return@repeat - } - } - } - } - } - return false - } - - /** - * 만료 시간 계산 (초 단위) - */ - private fun calculateExpiresIn(expiresAtString: String?): Long? { - return expiresAtString?.let { expiresAt -> - val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - val expiresAtTime = dateFormat.parse(expiresAt) - val currentTime = System.currentTimeMillis() - ((expiresAtTime?.time ?: currentTime) - currentTime) / 1000 // 초 단위로 변환 - } - } + override suspend fun refreshToken(): Boolean = tokenManager.refreshToken() } diff --git a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenAuthenticator.kt b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenAuthenticator.kt index 81b9077e..fb6d01ee 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenAuthenticator.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenAuthenticator.kt @@ -1,6 +1,6 @@ package com.alarmy.near.network.auth -import com.alarmy.near.data.repository.AuthRepository +import kotlinx.coroutines.runBlocking import okhttp3.Authenticator import okhttp3.Request import okhttp3.Response @@ -16,7 +16,7 @@ import javax.inject.Singleton class TokenAuthenticator @Inject constructor( - private val authRepository: AuthRepository, + private val tokenManager: TokenManager, ) : Authenticator { override fun authenticate(route: Route?, response: Response): Request? { @@ -25,32 +25,31 @@ constructor( return null } - // 토큰 갱신 시도 - val refreshSuccess = try { - authRepository.refreshToken() - } catch (e: Exception) { - false - } + // runBlocking을 사용하여 suspend 함수 호출 + return runBlocking { + try { + // 토큰 갱신 시도 + val refreshSuccess = tokenManager.refreshToken() - // 토큰 갱신 실패 시 null 반환 (재로그인 필요) - if (!refreshSuccess) { - return null - } + // 토큰 갱신 실패 시 null 반환 (재로그인 필요) + if (!refreshSuccess) { + return@runBlocking null + } - // 새로운 토큰으로 원래 요청 재시도 - val newToken = try { - authRepository.getCurrentUserToken() - } catch (e: Exception) { - return null - } + // 새로운 토큰으로 원래 요청 재시도 + val newToken = tokenManager.getAccessToken() - return if (newToken != null) { - response.request - .newBuilder() - .header("Authorization", "Bearer $newToken") - .build() - } else { - null + if (newToken != null) { + response.request + .newBuilder() + .header("Authorization", "Bearer $newToken") + .build() + } else { + null + } + } catch (e: Exception) { + null + } } } } diff --git a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt index e821ce7c..06a351a7 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt @@ -1,10 +1,6 @@ package com.alarmy.near.network.auth -import com.alarmy.near.data.local.datastore.TokenPreferences -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response @@ -19,18 +15,9 @@ import javax.inject.Singleton class TokenInterceptor @Inject constructor( - private val tokenPreferences: TokenPreferences, + private val tokenManager: TokenManager, ) : Interceptor { - private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private var currentToken: String? = null - private var tokenExpiresAt: Long? = null - - - init { - observeTokenChanges() - } - override fun intercept(chain: Interceptor.Chain): Response { val originalRequest = chain.request() @@ -38,12 +25,12 @@ constructor( return chain.proceed(originalRequest) } - // 현재 토큰 사용 - val validToken = getValidToken() - val requestWithAuth = if (validToken != null) { + // 현재 토큰을 헤더에 추가 + val token = runBlocking { tokenManager.getAccessToken() } + val requestWithAuth = if (token != null) { originalRequest .newBuilder() - .header("Authorization", "Bearer $validToken") + .header("Authorization", "Bearer $token") .build() } else { originalRequest @@ -53,58 +40,7 @@ constructor( return chain.proceed(requestWithAuth) } - /** - * 토큰 변화 관찰 - */ - private fun observeTokenChanges() { - coroutineScope.launch { - try { - // 초기 토큰 로드 - currentToken = tokenPreferences.getAccessToken() - tokenExpiresAt = tokenPreferences.getTokenExpiresAt() - - // 토큰 변화 실시간 관찰 - tokenPreferences.observeAccessToken().collect { token -> - currentToken = token - // 토큰이 변경되면 만료 시간도 다시 로드 - if (token != null) { - tokenExpiresAt = tokenPreferences.getTokenExpiresAt() - } else { - tokenExpiresAt = null - } - } - } catch (e: Exception) { - currentToken = null - tokenExpiresAt = null - } - } - } - - /** - * 유효한 토큰 반환 (만료 검사 포함) - */ - private fun getValidToken(): String? { - val token = currentToken - val expiresAt = tokenExpiresAt - - return if (token != null && !isTokenExpired(expiresAt)) { - token - } else { - null - } - } - - /** - * 토큰 만료 검사 - */ - private fun isTokenExpired(expiresAt: Long?): Boolean { - if (expiresAt == null) return true - return System.currentTimeMillis() >= expiresAt - } - - - - /** + /** * 인증이 필요없는 요청인지 확인 */ private fun isAuthExcludedRequest(request: Request): Boolean { diff --git a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenManager.kt b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenManager.kt new file mode 100644 index 00000000..8f21de4c --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenManager.kt @@ -0,0 +1,118 @@ +package com.alarmy.near.network.auth + +import com.alarmy.near.data.local.datastore.TokenPreferences +import com.alarmy.near.network.request.TokenRefreshRequest +import com.alarmy.near.network.service.AuthService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.text.SimpleDateFormat +import java.util.Locale +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +/** + * 토큰 관리자 + * 토큰 저장, 조회, 갱신을 담당하는 중앙 관리 클래스 + * Provider 패턴으로 순환 참조 해결 + */ +@Singleton +class TokenManager +@Inject +constructor( + private val tokenPreferences: TokenPreferences, + private val authServiceProvider: Provider, // Provider로 지연 주입 +) { + + private val refreshMutex = Mutex() + + /** + * 현재 액세스 토큰 가져오기 + */ + suspend fun getAccessToken(): String? { + return tokenPreferences.getAccessToken() + } + + /** + * 토큰 갱신 시도 + * @return 갱신 성공 여부 + */ + suspend fun refreshToken(): Boolean { + return refreshMutex.withLock { + try { + val refreshToken = tokenPreferences.getRefreshToken() ?: return false + + // Provider를 통해 AuthService 가져오기 (지연 주입) + val authService = authServiceProvider.get() + + // 토큰 갱신 API 호출 + val request = TokenRefreshRequest(refreshToken = refreshToken) + val response = authService.renewToken(request) + + // 새로운 토큰 저장 + val expiresIn = calculateExpiresIn(response.refreshTokenInfo?.expiresAt) + + tokenPreferences.saveTokens( + accessToken = response.accessToken, + refreshToken = response.refreshTokenInfo?.token, + expiresIn = expiresIn, + ) + + true + } catch (e: Exception) { + // 갱신 실패 시 토큰 삭제 + tokenPreferences.clearAllTokens() + false + } + } + } + + /** + * 토큰이 유효한지 확인 + */ + suspend fun hasValidToken(): Boolean { + return tokenPreferences.hasValidTokens() + } + + /** + * 모든 토큰 삭제 + */ + suspend fun clearAllTokens() { + tokenPreferences.clearAllTokens() + } + + /** + * 로그인 상태 관찰 + */ + fun observeLoginStatus(): Flow { + return tokenPreferences.observeLoginStatus() + } + + /** + * 토큰 저장 + */ + suspend fun saveTokens( + accessToken: String, + refreshToken: String?, + expiresIn: Long?, + ) { + tokenPreferences.saveTokens( + accessToken = accessToken, + refreshToken = refreshToken, + expiresIn = expiresIn, + ) + } + + /** + * 만료 시간 계산 (초 단위) + */ + fun calculateExpiresIn(expiresAtString: String?): Long? { + return expiresAtString?.let { expiresAt -> + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + val expiresAtTime = dateFormat.parse(expiresAt) + val currentTime = System.currentTimeMillis() + ((expiresAtTime?.time ?: currentTime) - currentTime) / 1000 // 초 단위로 변환 + } + } +} From 0800e556169ab3e392c27fbfbe5954e3aece6467 Mon Sep 17 00:00:00 2001 From: StopStone Date: Mon, 1 Sep 2025 23:15:27 +0900 Subject: [PATCH 046/100] =?UTF-8?q?refactor:=20=EC=B9=B4=EC=B9=B4=EC=98=A4?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카카오 로그인 시 context를 전역으로 참조하도록 변경 --- .../com/alarmy/near/data/datasource/KakaoDataSource.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/data/datasource/KakaoDataSource.kt b/Near/app/src/main/java/com/alarmy/near/data/datasource/KakaoDataSource.kt index 46b5c95c..17005d8c 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/datasource/KakaoDataSource.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/datasource/KakaoDataSource.kt @@ -29,9 +29,9 @@ class KakaoDataSource try { val token = if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { - loginWithKakaoTalk(context) + loginWithKakaoTalk() } else { - loginWithKakaoAccount(context) + loginWithKakaoAccount() } if (token.isNotEmpty()) { @@ -43,7 +43,7 @@ class KakaoDataSource Result.failure(exception) } - private suspend fun loginWithKakaoTalk(context: Context): String = + private suspend fun loginWithKakaoTalk(): String = suspendCancellableCoroutine { continuation -> UserApiClient.instance.loginWithKakaoTalk(context) { token, error -> when { @@ -62,7 +62,7 @@ class KakaoDataSource } } - private suspend fun loginWithKakaoAccount(context: Context): String = + private suspend fun loginWithKakaoAccount(): String = suspendCancellableCoroutine { continuation -> UserApiClient.instance.loginWithKakaoAccount(context) { token, error -> handleLoginResult(token, error, continuation) From cc27edc67fe64f656c62bab19f09cd6a90901cc0 Mon Sep 17 00:00:00 2001 From: StopStone Date: Mon, 1 Sep 2025 23:16:16 +0900 Subject: [PATCH 047/100] =?UTF-8?q?refactor:=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=82=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DataStore에서 직접 토큰 및 만료 시간을 가져와서 유효성 검사 - `isTokenExpired()` 메서드 호출 제거 및 로직 통합 --- .../alarmy/near/data/local/datastore/TokenPreferences.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt b/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt index 4258377b..13f5eaa0 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt @@ -59,8 +59,13 @@ class TokenPreferences * 토큰 존재 여부 확인 */ suspend fun hasValidTokens(): Boolean { - val accessToken = getAccessToken() - return !accessToken.isNullOrBlank() && !isTokenExpired() + val prefs = dataStore.data.first() + val accessToken = prefs[accessTokenKey] + if (accessToken.isNullOrBlank()) return false + + val expiresAt = prefs[expiresAtKey] + val isExpired = expiresAt == null || System.currentTimeMillis() >= expiresAt + return !isExpired } /** From c3edc18bbaafac1d07cb4c8862f5c9cda5ba839f Mon Sep 17 00:00:00 2001 From: StopStone Date: Mon, 1 Sep 2025 23:16:53 +0900 Subject: [PATCH 048/100] =?UTF-8?q?refactor:=20logout=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/alarmy/near/data/repository/AuthRepositoryImpl.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt index cd1252d6..6f967a23 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt @@ -87,11 +87,7 @@ constructor( } override suspend fun logout() { - try { - tokenManager.clearAllTokens() - } catch (exception: Exception) { - throw exception - } + tokenManager.clearAllTokens() } override suspend fun isLoggedIn(): Boolean = From 75855d3701ce0112298cfff0597a7b870ec5c502 Mon Sep 17 00:00:00 2001 From: StopStone Date: Mon, 1 Sep 2025 23:17:54 +0900 Subject: [PATCH 049/100] =?UTF-8?q?refactor:=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=A7=8C=EB=A3=8C=20=EC=8B=9C=EA=B0=84=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SimpleDateFormat에서 java.time.LocalDateTime으로 변경하여 스레드 안전성 확보 - DateTimeParseException 발생 시 null 반환하도록 수정 --- .../alarmy/near/network/auth/TokenManager.kt | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenManager.kt b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenManager.kt index 8f21de4c..29411a4e 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenManager.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenManager.kt @@ -6,8 +6,10 @@ import com.alarmy.near.network.service.AuthService import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import java.text.SimpleDateFormat -import java.util.Locale +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton @@ -24,16 +26,16 @@ constructor( private val tokenPreferences: TokenPreferences, private val authServiceProvider: Provider, // Provider로 지연 주입 ) { - + private val refreshMutex = Mutex() - + /** * 현재 액세스 토큰 가져오기 */ suspend fun getAccessToken(): String? { return tokenPreferences.getAccessToken() } - + /** * 토큰 갱신 시도 * @return 갱신 성공 여부 @@ -42,23 +44,23 @@ constructor( return refreshMutex.withLock { try { val refreshToken = tokenPreferences.getRefreshToken() ?: return false - + // Provider를 통해 AuthService 가져오기 (지연 주입) val authService = authServiceProvider.get() - + // 토큰 갱신 API 호출 val request = TokenRefreshRequest(refreshToken = refreshToken) val response = authService.renewToken(request) - + // 새로운 토큰 저장 val expiresIn = calculateExpiresIn(response.refreshTokenInfo?.expiresAt) - + tokenPreferences.saveTokens( accessToken = response.accessToken, refreshToken = response.refreshTokenInfo?.token, expiresIn = expiresIn, ) - + true } catch (e: Exception) { // 갱신 실패 시 토큰 삭제 @@ -67,28 +69,28 @@ constructor( } } } - + /** * 토큰이 유효한지 확인 */ suspend fun hasValidToken(): Boolean { return tokenPreferences.hasValidTokens() } - + /** * 모든 토큰 삭제 */ suspend fun clearAllTokens() { tokenPreferences.clearAllTokens() } - + /** * 로그인 상태 관찰 */ fun observeLoginStatus(): Flow { return tokenPreferences.observeLoginStatus() } - + /** * 토큰 저장 */ @@ -103,16 +105,21 @@ constructor( expiresIn = expiresIn, ) } - + /** * 만료 시간 계산 (초 단위) + * java.time 패키지 사용으로 스레드 안전성 보장 */ fun calculateExpiresIn(expiresAtString: String?): Long? { return expiresAtString?.let { expiresAt -> - val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - val expiresAtTime = dateFormat.parse(expiresAt) - val currentTime = System.currentTimeMillis() - ((expiresAtTime?.time ?: currentTime) - currentTime) / 1000 // 초 단위로 변환 + try { + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + val expiresAtTime = LocalDateTime.parse(expiresAt, formatter) + val expiresAtMillis = expiresAtTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + (expiresAtMillis - System.currentTimeMillis()) / 1000 // 초 단위로 변환 + } catch (e: DateTimeParseException) { + null + } } } } From 6ffe949f06430b42539c80ff44af9e7c6c64d441 Mon Sep 17 00:00:00 2001 From: stopstone Date: Tue, 2 Sep 2025 10:44:13 +0900 Subject: [PATCH 050/100] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=EB=A6=AC=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/alarmy/near/utils/logger/NearLog.kt | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 Near/app/src/main/java/com/alarmy/near/utils/logger/NearLog.kt diff --git a/Near/app/src/main/java/com/alarmy/near/utils/logger/NearLog.kt b/Near/app/src/main/java/com/alarmy/near/utils/logger/NearLog.kt new file mode 100644 index 00000000..d59ce7c6 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/utils/logger/NearLog.kt @@ -0,0 +1,135 @@ +package com.alarmy.near.utils.logger + +import android.util.Log +import com.alarmy.near.BuildConfig + +private const val TAG = "Near" +private const val STACK_TRACE_INDEX = 5 + +/** + * 호출자 정보를 포함한 로그 메시지를 생성합니다 + * 스택 트레이스 추출에 실패하면 원본 메시지를 반환합니다 + */ +private fun buildLogMessage(message: String): String { + return try { + val stackTrace = Thread.currentThread().stackTrace + + // 실제 호출자를 찾기 위해 스택을 순회 + for (i in 4 until stackTrace.size) { + val element = stackTrace[i] + val fileName = element.fileName ?: continue + val methodName = element.methodName ?: continue + + // 로그 관련 메서드들을 건너뛰고 실제 호출자 찾기 + if (!methodName.startsWith("log") && + !methodName.contains("\$default") && + !fileName.contains("Log") + ) { + val cleanFileName = + fileName + .replace(".java", "") + .replace(".kt", "") + + return "[$cleanFileName::$methodName (${element.fileName}:${element.lineNumber})] $message" + } + } + + // 찾지 못하면 기본 인덱스 사용 + if (stackTrace.size > STACK_TRACE_INDEX) { + val element = stackTrace[STACK_TRACE_INDEX] + val fileName = + element.fileName + ?.replace(".java", "") + ?.replace(".kt", "") + ?: "Unknown" + + "[$fileName::${element.methodName} (${element.fileName}:${element.lineNumber})] $message" + } else { + message + } + } catch (exception: Exception) { + // 스택 트레이스 추출 실패 시 원본 메시지 반환 + message + } +} + +/** + * 디버그 모드인지 확인합니다 + */ +private fun isLoggingEnabled(): Boolean = BuildConfig.DEBUG + +// Verbose 로그 +fun logv( + message: String, + tag: String = TAG, +) { + if (!isLoggingEnabled()) return + Log.v(tag, buildLogMessage(message)) +} + +// Debug 로그 +fun logd( + message: String, + tag: String = TAG, +) { + if (!isLoggingEnabled()) return + Log.d(tag, buildLogMessage(message)) +} + +// Info 로그 +fun logi( + message: String, + tag: String = TAG, +) { + if (!isLoggingEnabled()) return + Log.i(tag, buildLogMessage(message)) +} + +// Warning 로그 +fun logw( + message: String, + tag: String = TAG, +) { + if (!isLoggingEnabled()) return + Log.w(tag, buildLogMessage(message)) +} + +// Error 로그 +fun loge( + message: String, + tag: String = TAG, +) { + if (!isLoggingEnabled()) return + Log.e(tag, buildLogMessage(message)) +} + +// Error 로그 (이름 포함) +fun loge( + name: String, + message: String, + tag: String = TAG, +) { + if (!isLoggingEnabled()) return + Log.e(tag, buildLogMessage("$name: $message")) +} + +// Error 로그 (예외 포함) +fun loge( + message: String, + throwable: Throwable, + tag: String = TAG, +) { + if (!isLoggingEnabled()) return + Log.e(tag, buildLogMessage(message), throwable) +} + +// Error 로그 (이름과 예외 모두 포함) +fun loge( + name: String, + message: String, + throwable: Throwable, + tag: String = TAG, +) { + if (!isLoggingEnabled()) return + Log.e(tag, buildLogMessage("$name: $message"), throwable) +} From c6102a76219245a167353afebd5f06c522c7b28b Mon Sep 17 00:00:00 2001 From: stopstone Date: Tue, 2 Sep 2025 17:54:24 +0900 Subject: [PATCH 051/100] =?UTF-8?q?refactor:=20=EB=A1=9C=EA=B9=85=20?= =?UTF-8?q?=EC=8B=9C=20=ED=98=B8=EC=B6=9C=EC=9E=90=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=B0=BE=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존: 고정된 스택 인덱스를 사용하여 호출자 정보를 찾음 - 변경: 스택 트레이스를 순회하여 NearLog.kt 파일이 아닌 첫 번째 호출자를 찾도록 변경 - 추가: 스택 트레이스 접근 권한이 없거나 기타 예외 발생 시, 로그 메시지에 관련 정보를 포함하여 디버깅 용이성 향상 --- .../com/alarmy/near/utils/logger/NearLog.kt | 60 +++++++------------ 1 file changed, 23 insertions(+), 37 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/utils/logger/NearLog.kt b/Near/app/src/main/java/com/alarmy/near/utils/logger/NearLog.kt index d59ce7c6..307c93da 100644 --- a/Near/app/src/main/java/com/alarmy/near/utils/logger/NearLog.kt +++ b/Near/app/src/main/java/com/alarmy/near/utils/logger/NearLog.kt @@ -4,52 +4,38 @@ import android.util.Log import com.alarmy.near.BuildConfig private const val TAG = "Near" -private const val STACK_TRACE_INDEX = 5 +private const val LOGGER_FILE_NAME = "NearLog.kt" -/** - * 호출자 정보를 포함한 로그 메시지를 생성합니다 - * 스택 트레이스 추출에 실패하면 원본 메시지를 반환합니다 - */ private fun buildLogMessage(message: String): String { return try { val stackTrace = Thread.currentThread().stackTrace - // 실제 호출자를 찾기 위해 스택을 순회 - for (i in 4 until stackTrace.size) { - val element = stackTrace[i] - val fileName = element.fileName ?: continue - val methodName = element.methodName ?: continue - - // 로그 관련 메서드들을 건너뛰고 실제 호출자 찾기 - if (!methodName.startsWith("log") && - !methodName.contains("\$default") && - !fileName.contains("Log") - ) { - val cleanFileName = - fileName - .replace(".java", "") - .replace(".kt", "") - - return "[$cleanFileName::$methodName (${element.fileName}:${element.lineNumber})] $message" + // 스택 트레이스를 순회하며 로거 파일이 아닌 첫 번째 호출자를 찾습니다 + // 인덱스 0: Thread.getStackTrace() + // 인덱스 1: 현재 함수 (buildLogMessage) + // 인덱스 2~: 로그 함수들 (logd, loge 등) + // 그 이후: 실제 호출자 + val callerElement = + stackTrace.drop(2).firstOrNull { element -> + element.fileName != LOGGER_FILE_NAME } - } - // 찾지 못하면 기본 인덱스 사용 - if (stackTrace.size > STACK_TRACE_INDEX) { - val element = stackTrace[STACK_TRACE_INDEX] - val fileName = - element.fileName - ?.replace(".java", "") - ?.replace(".kt", "") - ?: "Unknown" - - "[$fileName::${element.methodName} (${element.fileName}:${element.lineNumber})] $message" - } else { - message + if (callerElement == null) { + return "[CallerNotFound] $message" } + + val fileName = + callerElement.fileName + ?.substringBeforeLast('.') + ?: "Unknown" + + val methodName = callerElement.methodName ?: "unknownMethod" + val lineNumber = callerElement.lineNumber + val originalFileName = callerElement.fileName ?: "Unknown" + + "[$fileName::$methodName ($originalFileName:$lineNumber)] $message" } catch (exception: Exception) { - // 스택 트레이스 추출 실패 시 원본 메시지 반환 - message + "[LogError:${exception.javaClass.simpleName}] $message" } } From 7bcd976eb1d7ad26d31c88a562839a3ef6f3887e Mon Sep 17 00:00:00 2001 From: stopstone Date: Tue, 2 Sep 2025 18:17:08 +0900 Subject: [PATCH 052/100] =?UTF-8?q?refactor:=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B2=B0=EA=B3=BC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LoginResult 모델을 제거하고 Result을 사용하도록 수정 --- .../near/data/repository/AuthRepository.kt | 5 +- .../data/repository/AuthRepositoryImpl.kt | 171 +++++++++--------- .../feature/login/LoginViewModel.kt | 22 +-- 3 files changed, 98 insertions(+), 100 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt index 0290d973..ded14dc6 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt @@ -1,12 +1,11 @@ package com.alarmy.near.data.repository -import com.alarmy.near.model.LoginResult import com.alarmy.near.model.ProviderType import kotlinx.coroutines.flow.Flow interface AuthRepository { // 소셜 로그인 수행 - suspend fun performSocialLogin(providerType: ProviderType): LoginResult + suspend fun performSocialLogin(providerType: ProviderType): Result /** * 소셜 로그인 수행 (토큰 직접 전달) @@ -14,7 +13,7 @@ interface AuthRepository { suspend fun socialLogin( accessToken: String, providerType: ProviderType, - ): LoginResult + ): Result // 로그아웃 수행 suspend fun logout() diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt index 6f967a23..43f51f80 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt @@ -1,7 +1,6 @@ package com.alarmy.near.data.repository import com.alarmy.near.data.datasource.SocialLoginProcessor -import com.alarmy.near.model.LoginResult import com.alarmy.near.model.ProviderType import com.alarmy.near.network.auth.TokenManager import com.alarmy.near.network.request.SocialLoginRequest @@ -11,100 +10,106 @@ import retrofit2.HttpException import javax.inject.Inject class AuthRepositoryImpl -@Inject -constructor( - private val authService: AuthService, - private val socialLoginProcessor: SocialLoginProcessor, - private val tokenManager: TokenManager, -) : AuthRepository { - override suspend fun performSocialLogin(providerType: ProviderType): LoginResult = - try { - val result = socialLoginProcessor.processLogin(providerType) + @Inject + constructor( + private val authService: AuthService, + private val socialLoginProcessor: SocialLoginProcessor, + private val tokenManager: TokenManager, + ) : AuthRepository { + override suspend fun performSocialLogin(providerType: ProviderType): Result = + try { + val result = socialLoginProcessor.processLogin(providerType) - if (result.isSuccess) { - val accessToken = result.getOrThrow() - socialLogin(accessToken, providerType) - } else { - val providerName = providerType.name.lowercase() - LoginResult( - isSuccess = false, - errorMessage = result.exceptionOrNull()?.message ?: "$providerName 로그인에 실패했습니다", + if (result.isSuccess) { + val accessToken = result.getOrThrow() + socialLogin(accessToken, providerType) + } else { + Result.failure( + createLoginException( + providerType = providerType, + errorMessage = result.exceptionOrNull()?.message, + defaultMessage = "로그인에 실패했습니다", + ), + ) + } + } catch (exception: Exception) { + Result.failure( + createLoginException( + providerType = providerType, + errorMessage = exception.message, + defaultMessage = "로그인 중 오류가 발생했습니다", + ), ) } - } catch (exception: Exception) { - val providerName = providerType.name.lowercase() - LoginResult( - isSuccess = false, - errorMessage = exception.message ?: "$providerName 로그인 중 오류가 발생했습니다", - ) - } - override suspend fun socialLogin( - accessToken: String, - providerType: ProviderType, - ): LoginResult = - try { - val request = SocialLoginRequest( - accessToken = accessToken, - providerType = providerType.name, - ) + override suspend fun socialLogin( + accessToken: String, + providerType: ProviderType, + ): Result = + try { + val request = + SocialLoginRequest( + accessToken = accessToken, + providerType = providerType.name, + ) - val response = authService.socialLogin(request) + val response = authService.socialLogin(request) - // 토큰 저장 - val expiresIn = tokenManager.calculateExpiresIn(response.refreshTokenInfo?.expiresAt) - tokenManager.saveTokens( - accessToken = response.accessToken, - refreshToken = response.refreshTokenInfo?.token, - expiresIn = expiresIn, - ) + // 토큰 저장 + val expiresIn = tokenManager.calculateExpiresIn(response.refreshTokenInfo?.expiresAt) + tokenManager.saveTokens( + accessToken = response.accessToken, + refreshToken = response.refreshTokenInfo?.token, + expiresIn = expiresIn, + ) - LoginResult( - isSuccess = true, - accessToken = response.accessToken, - refreshToken = response.refreshTokenInfo?.token, - ) - } catch (exception: HttpException) { - val errorMessage = when (exception.code()) { - 400 -> "잘못된 요청입니다" - 401 -> "소셜 로그인에 실패했습니다. 다시 시도해주세요." - 403 -> "접근이 거부되었습니다" - 500 -> "서버에 문제가 발생했습니다" - else -> "로그인 중 오류가 발생했습니다" + Result.success(Unit) + } catch (exception: HttpException) { + val errorMessage = getHttpErrorMessage(exception.code()) + Result.failure(Exception(errorMessage)) + } catch (exception: Exception) { + val errorMessage = exception.message ?: "알 수 없는 오류가 발생했습니다" + Result.failure(Exception(errorMessage)) } - LoginResult( - isSuccess = false, - errorMessage = errorMessage, - ) - } catch (exception: Exception) { - val errorMessage = exception.message ?: "알 수 없는 오류가 발생했습니다" - - LoginResult( - isSuccess = false, - errorMessage = errorMessage, - ) + override suspend fun logout() { + tokenManager.clearAllTokens() } - override suspend fun logout() { - tokenManager.clearAllTokens() - } + override suspend fun isLoggedIn(): Boolean = + try { + tokenManager.hasValidToken() + } catch (exception: Exception) { + false + } - override suspend fun isLoggedIn(): Boolean = - try { - tokenManager.hasValidToken() - } catch (exception: Exception) { - false - } + override suspend fun getCurrentUserToken(): String? = + try { + tokenManager.getAccessToken() + } catch (exception: Exception) { + null + } - override suspend fun getCurrentUserToken(): String? = - try { - tokenManager.getAccessToken() - } catch (exception: Exception) { - null - } + override fun observeLoginStatus(): Flow = tokenManager.observeLoginStatus() + + override suspend fun refreshToken(): Boolean = tokenManager.refreshToken() - override fun observeLoginStatus(): Flow = tokenManager.observeLoginStatus() + private fun createLoginException( + providerType: ProviderType, + errorMessage: String?, + defaultMessage: String, + ): Exception { + val finalMessage = errorMessage ?: "$providerType $defaultMessage" + return Exception(finalMessage) + } - override suspend fun refreshToken(): Boolean = tokenManager.refreshToken() -} + // TODO 추후 에러 메시지 변경 + private fun getHttpErrorMessage(httpCode: Int): String = + when (httpCode) { + 400 -> "잘못된 요청입니다" + 401 -> "소셜 로그인에 실패했습니다. 다시 시도해주세요." + 403 -> "접근이 거부되었습니다" + 500 -> "서버에 문제가 발생했습니다" + else -> "로그인 중 오류가 발생했습니다" + } + } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt index 1497ed8b..5f9a9c68 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt @@ -36,23 +36,17 @@ class LoginViewModel */ fun performLogin(providerType: ProviderType) { viewModelScope.launch { - try { - updateLoadingState(isLoading = true) + updateLoadingState(isLoading = true) - val loginResult = authRepository.performSocialLogin(providerType) - - updateLoadingState(isLoading = false) - - if (loginResult.isSuccess) { + authRepository.performSocialLogin(providerType) + .onSuccess { + updateLoadingState(isLoading = false) _loginSuccessEvent.send(Unit) - } else { - val errorMsg = loginResult.errorMessage ?: "로그인에 실패했습니다" - _errorEvent.send(Exception(errorMsg)) } - } catch (exception: Exception) { - updateLoadingState(isLoading = false) - _errorEvent.send(exception) - } + .onFailure { exception -> + updateLoadingState(isLoading = false) + _errorEvent.send(exception) + } } } From 2d3062db77baacaea2b223956dad05986d6dfd4b Mon Sep 17 00:00:00 2001 From: stopstone Date: Tue, 2 Sep 2025 18:37:54 +0900 Subject: [PATCH 053/100] =?UTF-8?q?Refactor:=20TokenInterceptor=EC=97=90?= =?UTF-8?q?=EC=84=9C=20AuthEndpoint=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 TokenInterceptor 내부에 있던 AuthEndpoint enum을 별도의 파일로 분리하여 관리하도록 수정했습니다. - 이를 통해 TokenInterceptor는 인증 로직에만 집중하고, 인증 예외 경로는 AuthEndpoint enum에서 관리하도록 역할을 분리했습니다. --- .../near/network/auth/TokenInterceptor.kt | 19 +++-------------- .../alarmy/near/network/model/AuthEndpoint.kt | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 16 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/network/model/AuthEndpoint.kt diff --git a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt index 06a351a7..225e547e 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt @@ -1,5 +1,6 @@ package com.alarmy.near.network.auth +import com.alarmy.near.network.model.AuthEndpoint import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Request @@ -40,25 +41,11 @@ constructor( return chain.proceed(requestWithAuth) } - /** + /** * 인증이 필요없는 요청인지 확인 */ private fun isAuthExcludedRequest(request: Request): Boolean { val url = request.url.toString() - return AuthEndpoint.EXCLUDED_PATHS.any { url.contains(it) } - } - - companion object { - enum class AuthEndpoint(val path: String) { - SOCIAL_LOGIN("/auth/social"), - TOKEN_RENEW("/auth/renew"); - - companion object { - val EXCLUDED_PATHS = listOf( - SOCIAL_LOGIN.path, - TOKEN_RENEW.path, - ) - } - } + return AuthEndpoint.excludedPaths.any { url.contains(it) } } } diff --git a/Near/app/src/main/java/com/alarmy/near/network/model/AuthEndpoint.kt b/Near/app/src/main/java/com/alarmy/near/network/model/AuthEndpoint.kt new file mode 100644 index 00000000..a19479ed --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/model/AuthEndpoint.kt @@ -0,0 +1,21 @@ +package com.alarmy.near.network.model + +/** + * 인증 관련 API 엔드포인트 + * + * 인증이 필요없는 경로들을 관리하여 TokenInterceptor에서 + * Authorization 헤더 추가를 제외할 수 있습니다. + */ +enum class AuthEndpoint(val path: String) { + SOCIAL_LOGIN("/auth/social"), + TOKEN_RENEW("/auth/renew"), + ; + + companion object { + /** + * 인증이 필요없는 모든 경로 목록 + * TokenInterceptor에서 이 경로들은 Authorization 헤더를 추가하지 않습니다 + */ + val excludedPaths: List = entries.map { it.path } + } +} From 9f3475a624169f514e65c2190bc633735283e008 Mon Sep 17 00:00:00 2001 From: stopstone Date: Tue, 2 Sep 2025 18:38:24 +0900 Subject: [PATCH 054/100] =?UTF-8?q?refactor:=20KakaoDataSource=20=EB=B0=94?= =?UTF-8?q?=EC=9D=B8=EB=94=A9=20DI=20=EB=AA=A8=EB=93=88=20=EB=B7=B0?= =?UTF-8?q?=E3=85=9C=E3=84=B4=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KakaoDataSource를 SocialLoginDataSource로 바인딩하는 로직을 RepositoryModule에서 DataSourceModule로 이동했습니다. --- .../com/alarmy/near/data/di/DataSourceModule.kt | 17 +++++++++++++++++ .../com/alarmy/near/data/di/RepositoryModule.kt | 7 ------- 2 files changed, 17 insertions(+), 7 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/data/di/DataSourceModule.kt diff --git a/Near/app/src/main/java/com/alarmy/near/data/di/DataSourceModule.kt b/Near/app/src/main/java/com/alarmy/near/data/di/DataSourceModule.kt new file mode 100644 index 00000000..4a6fb2b8 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/di/DataSourceModule.kt @@ -0,0 +1,17 @@ +package com.alarmy.near.data.di + +import com.alarmy.near.data.datasource.KakaoDataSource +import com.alarmy.near.data.datasource.SocialLoginDataSource +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(SingletonComponent::class) +interface DataSourceModule { + @Binds + @IntoSet + abstract fun bindKakaoDataSource(kakaoDataSource: KakaoDataSource): SocialLoginDataSource +} diff --git a/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt b/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt index 704b2c3d..5d73c37f 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt @@ -1,7 +1,5 @@ package com.alarmy.near.data.di -import com.alarmy.near.data.datasource.KakaoDataSource -import com.alarmy.near.data.datasource.SocialLoginDataSource import com.alarmy.near.data.repository.AuthRepository import com.alarmy.near.data.repository.AuthRepositoryImpl import com.alarmy.near.data.repository.DefaultFriendRepository @@ -12,7 +10,6 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import dagger.multibindings.IntoSet import javax.inject.Singleton @Module @@ -29,8 +26,4 @@ interface RepositoryModule { @Binds @Singleton abstract fun bindAuthRepository(authRepositoryImpl: AuthRepositoryImpl): AuthRepository - - @Binds - @IntoSet - abstract fun bindKakaoDataSource(kakaoDataSource: KakaoDataSource): SocialLoginDataSource } From e2fa26fb29993c4a3996d91cd91b39f98846ea9c Mon Sep 17 00:00:00 2001 From: stopstone Date: Tue, 2 Sep 2025 19:14:52 +0900 Subject: [PATCH 055/100] =?UTF-8?q?refactor:=20=EB=A1=9C=EA=B9=85=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20`NearLog`=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `NearLog`를 object로 변경하여 싱글턴으로 사용하도록 수정 - 로그 메시지 생성 로직을 `runCatching`으로 예외처리 - 중복되는 로그 출력 로직을 `writeLog` 함수로 통합 - `loge` 함수 오버로딩을 단일 함수로 통합하고, `name` 파라미터를 nullable로 변경 - 함수명을 `logv` -> `v`, `logd` -> `d` 등으로 축약하여 간결하게 변경 --- .../com/alarmy/near/utils/logger/NearLog.kt | 200 ++++++++---------- 1 file changed, 89 insertions(+), 111 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/utils/logger/NearLog.kt b/Near/app/src/main/java/com/alarmy/near/utils/logger/NearLog.kt index 307c93da..2d13c051 100644 --- a/Near/app/src/main/java/com/alarmy/near/utils/logger/NearLog.kt +++ b/Near/app/src/main/java/com/alarmy/near/utils/logger/NearLog.kt @@ -3,119 +3,97 @@ package com.alarmy.near.utils.logger import android.util.Log import com.alarmy.near.BuildConfig -private const val TAG = "Near" -private const val LOGGER_FILE_NAME = "NearLog.kt" - -private fun buildLogMessage(message: String): String { - return try { - val stackTrace = Thread.currentThread().stackTrace - - // 스택 트레이스를 순회하며 로거 파일이 아닌 첫 번째 호출자를 찾습니다 - // 인덱스 0: Thread.getStackTrace() - // 인덱스 1: 현재 함수 (buildLogMessage) - // 인덱스 2~: 로그 함수들 (logd, loge 등) - // 그 이후: 실제 호출자 - val callerElement = - stackTrace.drop(2).firstOrNull { element -> - element.fileName != LOGGER_FILE_NAME - } - - if (callerElement == null) { - return "[CallerNotFound] $message" +object NearLog { + private const val TAG = "Near" + private const val LOGGER_FILE_NAME = "NearLog.kt" + + private fun buildLogMessage(message: String): String = + runCatching { + val stackTrace = Thread.currentThread().stackTrace + + // 스택 트레이스를 순회하며 로거 파일이 아닌 첫 번째 호출자를 찾습니다 + // 인덱스 0: Thread.getStackTrace() + // 인덱스 1: 현재 함수 (buildLogMessage) + // 인덱스 2~: 로그 함수들 (d, e 등) + // 그 이후: 실제 호출자 + val callerElement = + stackTrace.drop(2).firstOrNull { element -> + element.fileName != LOGGER_FILE_NAME + } ?: throw IllegalStateException("Caller not found") + + val fileName = + callerElement.fileName + ?.substringBeforeLast('.') + ?: "Unknown" + + val methodName = callerElement.methodName ?: "unknownMethod" + val lineNumber = callerElement.lineNumber + val originalFileName = callerElement.fileName ?: "Unknown" + + "[$fileName::$methodName ($originalFileName:$lineNumber)] $message" + }.getOrElse { exception -> + // 스택 트레이스 분석 실패 시 간단한 포맷으로 대체하여 로깅 기능 유지 + "[LogError:${exception.javaClass.simpleName}] $message" } - val fileName = - callerElement.fileName - ?.substringBeforeLast('.') - ?: "Unknown" - - val methodName = callerElement.methodName ?: "unknownMethod" - val lineNumber = callerElement.lineNumber - val originalFileName = callerElement.fileName ?: "Unknown" - - "[$fileName::$methodName ($originalFileName:$lineNumber)] $message" - } catch (exception: Exception) { - "[LogError:${exception.javaClass.simpleName}] $message" + // 디버그 모드 확인 + private fun isLoggingEnabled(): Boolean = BuildConfig.DEBUG + + /** + * 공통 로그 출력 함수 + * 모든 로그 레벨에서 공통으로 사용되는 로직을 통합합니다 + */ + private fun writeLog( + level: Int, + tag: String, + message: String, + throwable: Throwable? = null, + ) { + if (!isLoggingEnabled()) return + + val formattedMessage = buildLogMessage(message) + + when (level) { + Log.VERBOSE -> Log.v(tag, formattedMessage) + Log.DEBUG -> Log.d(tag, formattedMessage) + Log.INFO -> Log.i(tag, formattedMessage) + Log.WARN -> Log.w(tag, formattedMessage) + Log.ERROR -> Log.e(tag, formattedMessage, throwable) + } } -} - -/** - * 디버그 모드인지 확인합니다 - */ -private fun isLoggingEnabled(): Boolean = BuildConfig.DEBUG - -// Verbose 로그 -fun logv( - message: String, - tag: String = TAG, -) { - if (!isLoggingEnabled()) return - Log.v(tag, buildLogMessage(message)) -} - -// Debug 로그 -fun logd( - message: String, - tag: String = TAG, -) { - if (!isLoggingEnabled()) return - Log.d(tag, buildLogMessage(message)) -} - -// Info 로그 -fun logi( - message: String, - tag: String = TAG, -) { - if (!isLoggingEnabled()) return - Log.i(tag, buildLogMessage(message)) -} -// Warning 로그 -fun logw( - message: String, - tag: String = TAG, -) { - if (!isLoggingEnabled()) return - Log.w(tag, buildLogMessage(message)) -} - -// Error 로그 -fun loge( - message: String, - tag: String = TAG, -) { - if (!isLoggingEnabled()) return - Log.e(tag, buildLogMessage(message)) -} - -// Error 로그 (이름 포함) -fun loge( - name: String, - message: String, - tag: String = TAG, -) { - if (!isLoggingEnabled()) return - Log.e(tag, buildLogMessage("$name: $message")) -} - -// Error 로그 (예외 포함) -fun loge( - message: String, - throwable: Throwable, - tag: String = TAG, -) { - if (!isLoggingEnabled()) return - Log.e(tag, buildLogMessage(message), throwable) -} - -// Error 로그 (이름과 예외 모두 포함) -fun loge( - name: String, - message: String, - throwable: Throwable, - tag: String = TAG, -) { - if (!isLoggingEnabled()) return - Log.e(tag, buildLogMessage("$name: $message"), throwable) + // Verbose 로그 + fun v( + message: String, + tag: String = TAG, + ) = writeLog(Log.VERBOSE, tag, message) + + // Debug 로그 + fun d( + message: String, + tag: String = TAG, + ) = writeLog(Log.DEBUG, tag, message) + + // Info 로그 + fun i( + message: String, + tag: String = TAG, + ) = writeLog(Log.INFO, tag, message) + + // Warning 로그 + fun w( + message: String, + tag: String = TAG, + ) = writeLog(Log.WARN, tag, message) + + // Error 로그 + fun e( + message: String, + throwable: Throwable? = null, + name: String? = null, + tag: String = TAG, + ) { + val finalMessage = if (name != null) "$name: $message" else message + writeLog(Log.ERROR, tag, finalMessage, throwable) + } } From 39d462b0abe7729eb7ad514b409c3ba727e478b4 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sat, 6 Sep 2025 14:35:38 +0900 Subject: [PATCH 056/100] =?UTF-8?q?fix:=20delete=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/alarmy/near/network/service/FriendService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 a07f12de..6aafb65d 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/service/FriendService.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/service/FriendService.kt @@ -1,6 +1,5 @@ package com.alarmy.near.network.service -import androidx.room.Delete import com.alarmy.near.network.request.FriendRequest import com.alarmy.near.network.response.CommonMessageEntity import com.alarmy.near.network.response.FriendEntity @@ -8,6 +7,7 @@ import com.alarmy.near.network.response.FriendRecordEntity import com.alarmy.near.network.response.FriendSummaryEntity import com.alarmy.near.network.response.MonthlyFriendEntity import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.PUT @@ -31,7 +31,7 @@ interface FriendService { @Body friendRequest: FriendRequest, ): FriendEntity - @Delete + @DELETE("/friend/{friendId}") suspend fun deleteFriend( @Path("friendId") friendId: String, ) From 7e3f7b385604b5fd592d50d2bc639e445ca42f90 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 7 Sep 2025 15:22:01 +0900 Subject: [PATCH 057/100] =?UTF-8?q?refactor:=20string=20res=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../friendprofile/FriendProfileScreen.kt | 2 +- .../FriendProfileEditorScreen.kt | 23 +++++++------- .../dialog/EditorExitDialog.kt | 11 +++---- Near/app/src/main/res/values/strings.xml | 30 ++++++++++++++----- 4 files changed, 41 insertions(+), 25 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index 16adc03f..a9659236 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -474,7 +474,7 @@ private fun RecordTab( Column(modifier = modifier.padding(horizontal = 24.dp)) { Spacer(modifier = Modifier.height(24.dp)) Text( - "챙김 기록", + stringResource(R.string.friend_profile_info_record_title_text), style = NearTheme.typography.B2_14_BOLD, color = NearTheme.colors.BLACK_1A1A1A, ) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index f7a03878..33cb7dc8 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -66,6 +67,8 @@ fun FriendProfileEditorRoute( ) { val friendProfileEditorUIState = viewModel.uiState.collectAsStateWithLifecycle() val warningDialogState = remember { mutableStateOf(false) } + val context = LocalContext.current + LaunchedEffect(viewModel.uiEvent) { launch { viewModel.uiEvent.collect { event -> @@ -84,7 +87,7 @@ fun FriendProfileEditorRoute( } FriendProfileEditorUIEvent.FriendProfileEditNetworkError -> { - onShowErrorSnackBar(IllegalStateException("네트워크 에러가 발생했습니다.")) + onShowErrorSnackBar(IllegalStateException(context.getString(R.string.network_error_message))) } is FriendProfileEditorUIEvent.FriendProfileEditSuccess -> { @@ -172,7 +175,7 @@ fun FriendProfileEditorScreen( Modifier.onNoRippleClick(onClick = { onSubmit() }), - text = "완료", + text = context.getString(R.string.friend_profile_editor_edit_complete_text), style = NearTheme.typography.B1_16_BOLD, color = NearTheme.colors.BLACK_1A1A1A, ) @@ -194,7 +197,7 @@ fun FriendProfileEditorScreen( modifier = Modifier.padding(top = 16.dp), text = buildAnnotatedString { - append("이름") + append(stringResource(R.string.friend_profile_editor_name)) withStyle( style = SpanStyle( @@ -220,7 +223,7 @@ fun FriendProfileEditorScreen( if (friendProfileEditorUIState.name.error) { Spacer(modifier = Modifier.height(8.dp)) Text( - "이름을 입력해주세요.", + stringResource(R.string.friend_profile_editor_enter_name), style = NearTheme.typography.FC_12_MEDIUM, color = NearTheme.colors.NEGATIVE_F04E4E, ) @@ -233,7 +236,7 @@ fun FriendProfileEditorScreen( verticalAlignment = Alignment.CenterVertically, ) { Text( - "관계", + stringResource(R.string.friend_profile_editor_relation), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.GRAY01_888888, ) @@ -498,7 +501,7 @@ fun FriendProfileEditorScreen( verticalAlignment = Alignment.CenterVertically, ) { Text( - "날짜", + stringResource(R.string.friend_profile_editor_date), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.GRAY01_888888, ) @@ -531,7 +534,7 @@ fun FriendProfileEditorScreen( ) { Text( friendProfileEditorUIState.anniversaries[index].date.value - ?: "날짜 선택", + ?: stringResource(R.string.friend_profile_editor_select_date), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLACK_1A1A1A, ) @@ -570,7 +573,7 @@ fun FriendProfileEditorScreen( ) { Text( modifier = Modifier.padding(top = 16.dp), - text = "메모", + text = stringResource(R.string.friend_profile_editor_memo), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.GRAY01_888888, ) @@ -585,9 +588,7 @@ fun FriendProfileEditorScreen( onMemoChanged(it) }, placeHolderText = - stringResource(R.string.friend_profile_editor_memo_default_text) + - "예) 날생선 X, 작년 생일에\n" + - "키링 선물함 등", + stringResource(R.string.friend_profile_editor_memo_default_text), maxTextCount = 100, ) Spacer(modifier = Modifier.height(80.dp)) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/dialog/EditorExitDialog.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/dialog/EditorExitDialog.kt index 5aa9c093..df55ac6d 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/dialog/EditorExitDialog.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/dialog/EditorExitDialog.kt @@ -6,8 +6,10 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.alarmy.near.R import com.alarmy.near.presentation.ui.theme.NearTheme @Composable @@ -20,27 +22,26 @@ internal fun EditorExitDialog( modifier = modifier, onDismissRequest = onDismissRequest, title = { - Text(text = "수정을 그만두시나요?") + Text(text = stringResource(R.string.editor_exit_title)) }, text = { Text( text = - "화면을 나가면 \n" + - "수정 내용은 저장되지 않아요.", + stringResource(R.string.editor_exit_content), ) }, confirmButton = { TextButton( onClick = onConfirm, ) { - Text("확인") + Text(stringResource(R.string.editor_exit_confirm)) } }, dismissButton = { TextButton( onClick = onDismissRequest, ) { - Text("취소") + Text(stringResource(R.string.editor_exit_dismiss)) } }, shape = RoundedCornerShape(24.dp), diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml index 02884bc5..cfd64955 100644 --- a/Near/app/src/main/res/values/strings.xml +++ b/Near/app/src/main/res/values/strings.xml @@ -6,6 +6,7 @@ 뒤로 가기 메뉴 + 네트워크 에러가 발생했습니다. MY @@ -34,13 +35,6 @@ 친구 가족 지인 - - - 매일 - 매주 - 2주 - 매달 - 6개월 프로필 상세 친구 가족 @@ -49,7 +43,7 @@ 생일 날짜 선택 삭제하기 - 꼭 기억해야 할 내용을 기록해보세요.\n + 꼭 기억해야 할 내용을 기록해보세요.\n예) 날생선 X, 작년 생일에 키링 선물함 등 이름을 입력해주세요. 기념일 이름 추가하기 @@ -60,5 +54,25 @@ 삭제 이번달은 챙길 사람이 없네요. %1$d번째 챙김 + 챙김 기록 + 완료 + 이름 + 이름을 입력해주세요. + 관계 + 날짜 + 메모 + + + 매일 + 매주 + 2주 + 매달 + 6개월 + + + 수정을 그만두시나요? + 화면을 나가면 \n수정 내용은 저장되지 않아요. + 확인 + 취소 From a42d27a92e1d6b104e6549ba3407aba71d8519f6 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 7 Sep 2025 15:26:47 +0900 Subject: [PATCH 058/100] =?UTF-8?q?refactor:=20=EC=97=B0=EB=9D=BD=20?= =?UTF-8?q?=EB=B9=88=EB=8F=84=20=EB=A7=A4=EC=A7=81=EB=84=98=EB=B2=84=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/alarmy/near/data/mapper/FriendSummaryMapper.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt index 07450b6a..c1811712 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt @@ -4,6 +4,10 @@ import com.alarmy.near.model.friendsummary.ContactFrequencyLevel import com.alarmy.near.model.friendsummary.FriendSummary import com.alarmy.near.network.response.FriendSummaryEntity +private val CONTACT_FREQUENCY_LOW_RANGE = 0..29 +private val CONTACT_FREQUENCY_MIDDLE_RANGE = 30..69 +private val CONTACT_FREQUENCY_HIGH_RANGE = 70..100 + fun FriendSummaryEntity.toModel(): FriendSummary = FriendSummary( id = friendId, @@ -13,9 +17,9 @@ fun FriendSummaryEntity.toModel(): FriendSummary = isContacted = true, contactFrequencyLevel = when (checkRate) { - in 0..29 -> ContactFrequencyLevel.LOW - in 30..69 -> ContactFrequencyLevel.MIDDLE - in 70..100 -> ContactFrequencyLevel.HIGH + in CONTACT_FREQUENCY_LOW_RANGE -> ContactFrequencyLevel.LOW + in CONTACT_FREQUENCY_MIDDLE_RANGE -> ContactFrequencyLevel.MIDDLE + in CONTACT_FREQUENCY_HIGH_RANGE -> ContactFrequencyLevel.HIGH else -> ContactFrequencyLevel.LOW }, ) From 90ed514deae88f234c11919dca4aa34d51707910 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 7 Sep 2025 15:27:36 +0900 Subject: [PATCH 059/100] =?UTF-8?q?fix:=20context=20->=20stringres=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../friendprofileedittor/FriendProfileEditorScreen.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index 33cb7dc8..2c49e475 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -175,7 +175,7 @@ fun FriendProfileEditorScreen( Modifier.onNoRippleClick(onClick = { onSubmit() }), - text = context.getString(R.string.friend_profile_editor_edit_complete_text), + text = stringResource(R.string.friend_profile_editor_edit_complete_text), style = NearTheme.typography.B1_16_BOLD, color = NearTheme.colors.BLACK_1A1A1A, ) @@ -397,8 +397,7 @@ fun FriendProfileEditorScreen( end = 12.dp, top = 14.dp, bottom = 14.dp, - ) - .onNoRippleClick({ + ).onNoRippleClick({ birthdayDatePickerState.value = true }), verticalAlignment = Alignment.CenterVertically, From 9bfdb65cfcf4797c27a7e87486935c6bd496041a Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 7 Sep 2025 16:28:59 +0900 Subject: [PATCH 060/100] =?UTF-8?q?refactor:=20dayOfWeek=20enum=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alarmy/near/data/mapper/FriendMapper.kt | 5 ++-- .../main/java/com/alarmy/near/model/Friend.kt | 17 ++++++++++- .../near/network/response/FriendEntity.kt | 1 + .../friendprofile/FriendProfileScreen.kt | 3 +- .../FriendProfileEditorScreen.kt | 11 ++++--- .../uistate/FriendProfileEditorUIState.kt | 29 ++----------------- Near/app/src/main/res/values/strings.xml | 7 +++++ 7 files changed, 38 insertions(+), 35 deletions(-) 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 3e19c710..68203c95 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt @@ -2,6 +2,7 @@ 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 @@ -29,7 +30,7 @@ fun FriendEntity.toModel(): Friend = fun ContactFrequencyEntity.toModel(): ContactFrequency = ContactFrequency( reminderInterval = ReminderInterval.valueOf(contactWeek), - dayOfWeek = dayOfWeek, + dayOfWeek = DayOfWeek.valueOf(dayOfWeek), ) fun AnniversaryEntity.toModel(): Anniversary = @@ -53,7 +54,7 @@ fun Friend.toRequest(): FriendRequest = fun ContactFrequency.toRequest(): ContactFrequencyRequest = ContactFrequencyRequest( contactWeek = reminderInterval.toString(), - dayOfWeek = dayOfWeek, + dayOfWeek = dayOfWeek.toString(), ) fun Anniversary.toRequest(): AnniversaryRequest = diff --git a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt index 8cba165a..b37b23a6 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt @@ -1,6 +1,8 @@ package com.alarmy.near.model import android.os.Parcelable +import androidx.annotation.StringRes +import com.alarmy.near.R import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import java.time.LocalDate @@ -36,7 +38,7 @@ data class Friend( @Parcelize data class ContactFrequency( val reminderInterval: ReminderInterval, - val dayOfWeek: String, + val dayOfWeek: DayOfWeek, ) : Parcelable @Serializable @@ -46,3 +48,16 @@ data class Anniversary( val title: String, val date: String? = null, ) : Parcelable + +@Serializable +enum class DayOfWeek( + @param:StringRes val resId: Int, +) { + MONDAY(R.string.day_of_week_monday), + TUESDAY(R.string.day_of_week_tuesday), + WEDNESDAY(R.string.day_of_week_wednesday), + THURSDAY(R.string.day_of_week_thursday), + FRIDAY(R.string.day_of_week_friday), + SATURDAY(R.string.day_of_week_saturday), + SUNDAY(R.string.day_of_week_sunday), +} diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt b/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt index 16e38353..dcea2b5c 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt @@ -1,5 +1,6 @@ package com.alarmy.near.network.response +import com.alarmy.near.model.DayOfWeek import kotlinx.serialization.Serializable @Serializable diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index a9659236..481d6175 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -59,6 +59,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.DayOfWeek import com.alarmy.near.model.Friend import com.alarmy.near.model.FriendRecord import com.alarmy.near.model.Relation @@ -701,7 +702,7 @@ fun FriendProfileScreenPreview() { contactFrequency = ContactFrequency( reminderInterval = ReminderInterval.EVERY_TWO_WEEK, - dayOfWeek = "MONDAY", + dayOfWeek = DayOfWeek.THURSDAY, ), birthday = "1998-11-13", anniversaryList = listOf(), diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index 2c49e475..6a45bb50 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width @@ -42,6 +43,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.DayOfWeek import com.alarmy.near.model.Friend import com.alarmy.near.model.Relation import com.alarmy.near.model.ReminderInterval @@ -138,6 +140,7 @@ fun FriendProfileEditorScreen( ) { val density = LocalDensity.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } + val navigationBarHeightDp = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } val showBottomSheet = remember { mutableStateOf(false) } if (showBottomSheet.value) { ReminderIntervalBottomSheet(onDismissRequest = { @@ -161,10 +164,10 @@ fun FriendProfileEditorScreen( modifier = Modifier .fillMaxSize() - .background(NearTheme.colors.WHITE_FFFFFF), + .background(NearTheme.colors.WHITE_FFFFFF) + .padding(top = statusBarHeightDp, bottom = navigationBarHeightDp), ) { item { - Spacer(modifier = Modifier.padding(top = statusBarHeightDp)) NearTopAppbar( modifier = Modifier.padding(end = 24.dp), title = "", @@ -338,7 +341,7 @@ fun FriendProfileEditorScreen( stringResource(friendProfileEditorUIState.contactFrequency.reminderInterval.labelRes) + stringResource( R.string.friend_profile_editor_contact_period_format, - friendProfileEditorUIState.contactFrequency.dayOfWeek, + stringResource(friendProfileEditorUIState.contactFrequency.dayOfWeek.resId), ), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLACK_1A1A1A, @@ -606,7 +609,7 @@ fun FriendProfileEditorScreenPreview() { contactFrequency = ContactFrequency( reminderInterval = ReminderInterval.EVERY_DAY, - dayOfWeek = "2025-01-01", + dayOfWeek = DayOfWeek.SATURDAY, ), ), ) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt index 3d2f2b04..4e92060a 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt @@ -29,20 +29,7 @@ fun Friend.toUiModel(): FriendProfileEditorUIState = FriendProfileEditorUIState( name = InputField(name), relation = relation, - contactFrequency = - contactFrequency.copy( - dayOfWeek = - when (contactFrequency.dayOfWeek) { - "MONDAY" -> "월요일" - "TUESDAY" -> "화요일" - "WEDNESDAY" -> "수요일" - "THURSDAY" -> "목요일" - "FRIDAY" -> "금요일" - "SATURDAY" -> "토요일" - "SUNDAY" -> "일요일" - else -> IllegalStateException("없는 타입 입니다.") - } as String, - ), + contactFrequency = contactFrequency, birthday = InputField(birthday), anniversaries = anniversaryList.map { it.toUiModel() }, memo = InputField(memo), @@ -64,19 +51,7 @@ fun FriendProfileEditorUIState.toModel( name = name.value, relation = relation, contactFrequency = - contactFrequency.copy( - dayOfWeek = - when (contactFrequency.dayOfWeek) { - "월요일" -> "MONDAY" - "화요일" -> "TUESDAY" - "수요일" -> "WEDNESDAY" - "목요일" -> "THURSDAY" - "금요일" -> "FRIDAY" - "토요일" -> "SATURDAY" - "일요일" -> "SUNDAY" - else -> IllegalStateException("없는 타입 입니다.") - } as String, - ), + contactFrequency, birthday = birthday.value?.replace(".", "-"), anniversaryList = anniversaries.map { diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml index cfd64955..3f1f4106 100644 --- a/Near/app/src/main/res/values/strings.xml +++ b/Near/app/src/main/res/values/strings.xml @@ -74,5 +74,12 @@ 화면을 나가면 \n수정 내용은 저장되지 않아요. 확인 취소 + 월요일 + 화요일 + 수요일 + 목요일 + 금요일 + 토요일 + 일요일 From ea1c634fbbf8e734d29898636d16865b2dde0c93 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 7 Sep 2025 16:30:35 +0900 Subject: [PATCH 061/100] =?UTF-8?q?fix:=20=EB=84=A4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=EB=B0=94=20=ED=8C=A8=EB=94=A9=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/feature/friendprofile/FriendProfileScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index 481d6175..c56a31ff 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars @@ -142,13 +143,14 @@ fun FriendProfileScreen( ) { val density = LocalDensity.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } + val navigationBarHeightDp = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } val currentTabPosition = remember { mutableIntStateOf(0) } val dropdownState = remember { mutableStateOf(false) } Box( modifier = modifier .background(NearTheme.colors.WHITE_FFFFFF) - .padding(top = statusBarHeightDp, bottom = 24.dp), + .padding(top = statusBarHeightDp, bottom = navigationBarHeightDp + 24.dp), ) { when (friendState) { is FriendState.Success -> { From ea1bdae25a9539d0e959577d7b1661d70add01df Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 7 Sep 2025 16:34:20 +0900 Subject: [PATCH 062/100] =?UTF-8?q?fix:=20records=20isEmpty=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EB=A5=BC=20=ED=86=B5=ED=95=9C=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/feature/friendprofile/FriendProfileScreen.kt | 2 +- .../feature/friendprofile/FriendProfileViewModel.kt | 2 ++ .../feature/friendprofile/uistate/FriendShipRecordState.kt | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index c56a31ff..cd8140ca 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -481,7 +481,7 @@ private fun RecordTab( style = NearTheme.typography.B2_14_BOLD, color = NearTheme.colors.BLACK_1A1A1A, ) - if (friendShipRecordState.records.isEmpty()) { + if (friendShipRecordState.isEmpty) { Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { Spacer(modifier = Modifier.height(60.dp)) Image(painterResource(R.drawable.img_100_character_empty), contentDescription = null) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt index ec245001..141e4594 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt @@ -69,12 +69,14 @@ class FriendProfileViewModel _friendShipRecordStateFlow.update { it.copy( records = records.filter { record -> record.isChecked }, + isEmpty = records.isEmpty(), isLoading = false, ) } }.catch { error -> _friendShipRecordStateFlow.update { it.copy( + isEmpty = true, isLoading = false, ) } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendShipRecordState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendShipRecordState.kt index e5041ff5..8fa62e68 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendShipRecordState.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendShipRecordState.kt @@ -4,5 +4,6 @@ import com.alarmy.near.model.FriendRecord data class FriendShipRecordState( val records: List = emptyList(), + val isEmpty: Boolean = true, val isLoading: Boolean = false, ) From 5b0a1868444c4ff401581fc9f90e0caf0be1ff67 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 24 Aug 2025 17:56:56 +0900 Subject: [PATCH 063/100] =?UTF-8?q?feat:=20=EC=B9=9C=EA=B5=AC=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20Service=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alarmy/near/data/mapper/FriendMapper.kt | 8 ++-- .../near/network/request/FriendRequest.kt | 27 ++++++++++++++ .../network/response/CommonMessageEntity.kt | 8 ++++ .../near/network/response/FriendEntity.kt | 27 +++++++++++--- .../network/response/FriendRecordEntity.kt | 6 +++ .../network/response/FriendSummaryEntity.kt | 15 ++++++++ .../near/network/service/FriendService.kt | 37 ++++++++++++++++++- 7 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/network/request/FriendRequest.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/network/response/CommonMessageEntity.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/network/response/FriendRecordEntity.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/network/response/FriendSummaryEntity.kt diff --git a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt index fcfc70c5..2ec9445c 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 @@ -2,9 +2,9 @@ package com.alarmy.near.data.mapper import com.alarmy.near.model.ContactFrequency import com.alarmy.near.model.FriendSummary -import com.alarmy.near.network.response.FriendEntity +import com.alarmy.near.network.response.FriendSummaryEntity -fun FriendEntity.toModel(): FriendSummary = +fun FriendSummaryEntity.toModel(): FriendSummary = FriendSummary( id = friendId, name = name, @@ -17,5 +17,5 @@ fun FriendEntity.toModel(): FriendSummary = in 30..69 -> ContactFrequency.MIDDLE in 70..100 -> ContactFrequency.HIGH else -> ContactFrequency.LOW - }, - ) + }, + ) diff --git a/Near/app/src/main/java/com/alarmy/near/network/request/FriendRequest.kt b/Near/app/src/main/java/com/alarmy/near/network/request/FriendRequest.kt new file mode 100644 index 00000000..1aa1099e --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/request/FriendRequest.kt @@ -0,0 +1,27 @@ +package com.alarmy.near.network.request + +import kotlinx.serialization.Serializable + +@Serializable +data class FriendRequest( + val name: String, + val relation: String, + val contactFrequency: ContactFrequencyRequest, + val birthday: String?, + val anniversaryList: List, + val memo: String?, + val phone: String?, +) + +@Serializable +data class ContactFrequencyRequest( + val contactWeek: String, + val dayOfWeek: String, +) + +@Serializable +data class AnniversaryRequest( + val id: Int, + val title: String, + val date: String, +) diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/CommonMessageEntity.kt b/Near/app/src/main/java/com/alarmy/near/network/response/CommonMessageEntity.kt new file mode 100644 index 00000000..6566e2bf --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/response/CommonMessageEntity.kt @@ -0,0 +1,8 @@ +package com.alarmy.near.network.response + +import kotlinx.serialization.Serializable + +@Serializable +data class CommonMessageEntity( + val message: String, +) diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt b/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt index 7ec3ab3d..3862ebd7 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt @@ -5,11 +5,26 @@ import kotlinx.serialization.Serializable @Serializable data class FriendEntity( val friendId: String, - val position: Int, - val source: String, + val imageUrl: String, + val relation: String, val name: String, - val imageUrl: String? = null, - val fileName: String? = null, - val checkRate: Int, - val lastContactAt: String? = null, + val contactFrequencyEntity: ContactFrequencyEntity, + val birthday: String?, + val anniversaryEntityList: List, + val memo: String?, + val phone: String?, + val lastContactAt: String?, +) + +@Serializable +data class ContactFrequencyEntity( + val contactWeek: String, + val dayOfWeek: String, +) + +@Serializable +data class AnniversaryEntity( + val id: Int, + val title: String, + val date: String, ) diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/FriendRecordEntity.kt b/Near/app/src/main/java/com/alarmy/near/network/response/FriendRecordEntity.kt new file mode 100644 index 00000000..ccc9355b --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/response/FriendRecordEntity.kt @@ -0,0 +1,6 @@ +package com.alarmy.near.network.response + +data class FriendRecordEntity( + val isChecked: Boolean, + val createdAt: String, +) diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/FriendSummaryEntity.kt b/Near/app/src/main/java/com/alarmy/near/network/response/FriendSummaryEntity.kt new file mode 100644 index 00000000..f6a6c55b --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/response/FriendSummaryEntity.kt @@ -0,0 +1,15 @@ +package com.alarmy.near.network.response + +import kotlinx.serialization.Serializable + +@Serializable +data class FriendSummaryEntity( + val friendId: String, + val position: Int, + val source: String, + val name: String, + val imageUrl: String? = null, + val fileName: String? = null, + val checkRate: Int, + val lastContactAt: String? = null, +) diff --git a/Near/app/src/main/java/com/alarmy/near/network/service/FriendService.kt b/Near/app/src/main/java/com/alarmy/near/network/service/FriendService.kt index 0beca5dc..a07f12de 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/service/FriendService.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/service/FriendService.kt @@ -1,13 +1,48 @@ package com.alarmy.near.network.service +import androidx.room.Delete +import com.alarmy.near.network.request.FriendRequest +import com.alarmy.near.network.response.CommonMessageEntity import com.alarmy.near.network.response.FriendEntity +import com.alarmy.near.network.response.FriendRecordEntity +import com.alarmy.near.network.response.FriendSummaryEntity import com.alarmy.near.network.response.MonthlyFriendEntity +import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path interface FriendService { @GET("/friend/list") - suspend fun fetchFriends(): List + suspend fun fetchFriends(): List @GET("/friend/monthly") suspend fun fetchMonthlyFriends(): List + + @GET("/friend/{friendId}") + suspend fun fetchFriendById( + @Path("friendId") friendId: String, + ): FriendEntity + + @PUT("/friend/{friendId}") + suspend fun updateFriend( + @Path("friendId") friendId: String, + @Body friendRequest: FriendRequest, + ): FriendEntity + + @Delete + suspend fun deleteFriend( + @Path("friendId") friendId: String, + ) + + @GET("/friend/record/{friendId}") + suspend fun fetchFriendRecord( + @Path("friendId") friendId: String, + ): List + + @POST("/friend/record/{friendId}") + suspend fun recordContact( + @Path("friendId") friendId: String, + ): CommonMessageEntity } From 369fd4d3567884a270639ce984c3218fcc0f172b Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 24 Aug 2025 18:02:53 +0900 Subject: [PATCH 064/100] =?UTF-8?q?refactor:=20ContactFrequency=20->=20Con?= =?UTF-8?q?tactFrequencyLevel=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/alarmy/near/data/mapper/FriendMapper.kt | 12 ++++++------ ...{ContactFrequency.kt => ContactFrequencyLevel.kt} | 2 +- .../main/java/com/alarmy/near/model/FriendSummary.kt | 2 +- .../near/presentation/feature/home/HomeScreen.kt | 4 ++-- .../feature/home/component/ContactItem.kt | 12 ++++++------ .../feature/home/component/MyContacts.kt | 5 ++--- 6 files changed, 18 insertions(+), 19 deletions(-) rename Near/app/src/main/java/com/alarmy/near/model/{ContactFrequency.kt => ContactFrequencyLevel.kt} (64%) 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 2ec9445c..c6468ee0 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt @@ -1,6 +1,6 @@ package com.alarmy.near.data.mapper -import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.ContactFrequencyLevel import com.alarmy.near.model.FriendSummary import com.alarmy.near.network.response.FriendSummaryEntity @@ -11,11 +11,11 @@ fun FriendSummaryEntity.toModel(): FriendSummary = profileImageUrl = imageUrl, lastContactedAt = lastContactAt, isContacted = true, - contactFrequency = + contactFrequencyLevel = when (checkRate) { - in 0..29 -> ContactFrequency.LOW - in 30..69 -> ContactFrequency.MIDDLE - in 70..100 -> ContactFrequency.HIGH - else -> ContactFrequency.LOW + in 0..29 -> ContactFrequencyLevel.LOW + in 30..69 -> ContactFrequencyLevel.MIDDLE + in 70..100 -> ContactFrequencyLevel.HIGH + else -> ContactFrequencyLevel.LOW }, ) diff --git a/Near/app/src/main/java/com/alarmy/near/model/ContactFrequency.kt b/Near/app/src/main/java/com/alarmy/near/model/ContactFrequencyLevel.kt similarity index 64% rename from Near/app/src/main/java/com/alarmy/near/model/ContactFrequency.kt rename to Near/app/src/main/java/com/alarmy/near/model/ContactFrequencyLevel.kt index dfcea4a5..3d5c0df0 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/ContactFrequency.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/ContactFrequencyLevel.kt @@ -1,6 +1,6 @@ package com.alarmy.near.model -enum class ContactFrequency { +enum class ContactFrequencyLevel { LOW, MIDDLE, HIGH, diff --git a/Near/app/src/main/java/com/alarmy/near/model/FriendSummary.kt b/Near/app/src/main/java/com/alarmy/near/model/FriendSummary.kt index 49249003..b0fddf88 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/FriendSummary.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/FriendSummary.kt @@ -9,5 +9,5 @@ data class FriendSummary( val profileImageUrl: String?, val lastContactedAt: String?, val isContacted: Boolean, - val contactFrequency: ContactFrequency, + val contactFrequencyLevel: ContactFrequencyLevel, ) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeScreen.kt index 61612787..0f16dcaf 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 @@ -52,7 +52,7 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R -import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.ContactFrequencyLevel import com.alarmy.near.model.FriendSummary import com.alarmy.near.model.monthly.MonthlyFriend import com.alarmy.near.model.monthly.MonthlyFriendType @@ -388,7 +388,7 @@ internal fun HomeScreenPreview() { profileImageUrl = "https://search.yahoo.com/search?p=partiendo", lastContactedAt = "2025-07-16", isContacted = false, - contactFrequency = ContactFrequency.HIGH, + contactFrequencyLevel = ContactFrequencyLevel.HIGH, ) }, monthlyFriends = diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt index 15fd3d28..ed45d6a9 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.alarmy.near.R -import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.ContactFrequencyLevel import com.alarmy.near.model.FriendSummary import com.alarmy.near.presentation.ui.extension.onNoRippleClick import com.alarmy.near.presentation.ui.theme.NearTheme @@ -50,10 +50,10 @@ fun ContactItem( .align(Alignment.TopEnd) .offset(x = 4.dp, y = (-4).dp), painter = - when (friendSummary.contactFrequency) { - ContactFrequency.LOW -> painterResource(R.drawable.ic_visual_24_emoji_0) - ContactFrequency.MIDDLE -> painterResource(R.drawable.ic_visual_24_emoji_50) - ContactFrequency.HIGH -> painterResource(R.drawable.ic_visual_24_emoji_100) + when (friendSummary.contactFrequencyLevel) { + ContactFrequencyLevel.LOW -> painterResource(R.drawable.ic_visual_24_emoji_0) + ContactFrequencyLevel.MIDDLE -> painterResource(R.drawable.ic_visual_24_emoji_50) + ContactFrequencyLevel.HIGH -> painterResource(R.drawable.ic_visual_24_emoji_100) }, contentDescription = "", ) @@ -99,7 +99,7 @@ fun ContactItemPreview() { profileImageUrl = "", lastContactedAt = "2025-04-21", isContacted = true, - contactFrequency = ContactFrequency.HIGH, + contactFrequencyLevel = ContactFrequencyLevel.HIGH, ), ) } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt index c09f3796..1376552e 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt @@ -17,10 +17,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.ContactFrequencyLevel import com.alarmy.near.model.FriendSummary import com.alarmy.near.presentation.ui.theme.NearTheme -import java.time.LocalDate private const val OVERFLOW_WIDTH_OF_CONTACT_ITEM_BY_NAME_TEXT = 34 @@ -240,7 +239,7 @@ fun MyContactsPreview() { profileImageUrl = "https://search.yahoo.com/search?p=partiendo", lastContactedAt = "2025-04-21", isContacted = false, - contactFrequency = ContactFrequency.LOW, + contactFrequencyLevel = ContactFrequencyLevel.LOW, ) }.chunked(5), ) From 9a66d1a130e2f11f2fdc9b8cc1cc0e6a42ed3ccb Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 24 Aug 2025 18:09:30 +0900 Subject: [PATCH 065/100] =?UTF-8?q?feat:=20repository=20API=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alarmy/near/data/mapper/FriendMapper.kt | 73 +++++++++++++++---- .../near/data/mapper/FriendRecordMapper.kt | 10 +++ .../near/data/mapper/FriendSummaryMapper.kt | 21 ++++++ .../repository/DefaultFriendRepository.kt | 33 +++++++++ .../near/data/repository/FriendRepository.kt | 15 ++++ .../main/java/com/alarmy/near/model/Friend.kt | 25 +++++++ .../com/alarmy/near/model/FriendRecord.kt | 6 ++ 7 files changed, 167 insertions(+), 16 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/data/mapper/FriendRecordMapper.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/model/Friend.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/model/FriendRecord.kt 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 c6468ee0..0aab0634 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt @@ -1,21 +1,62 @@ package com.alarmy.near.data.mapper -import com.alarmy.near.model.ContactFrequencyLevel -import com.alarmy.near.model.FriendSummary -import com.alarmy.near.network.response.FriendSummaryEntity +import com.alarmy.near.model.Anniversary +import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.Friend +import com.alarmy.near.network.request.AnniversaryRequest +import com.alarmy.near.network.request.ContactFrequencyRequest +import com.alarmy.near.network.request.FriendRequest +import com.alarmy.near.network.response.AnniversaryEntity +import com.alarmy.near.network.response.ContactFrequencyEntity +import com.alarmy.near.network.response.FriendEntity -fun FriendSummaryEntity.toModel(): FriendSummary = - FriendSummary( - id = friendId, +fun FriendEntity.toModel(): Friend = + Friend( + friendId = friendId, + imageUrl = imageUrl, + relation = relation, name = name, - profileImageUrl = imageUrl, - lastContactedAt = lastContactAt, - isContacted = true, - contactFrequencyLevel = - when (checkRate) { - in 0..29 -> ContactFrequencyLevel.LOW - in 30..69 -> ContactFrequencyLevel.MIDDLE - in 70..100 -> ContactFrequencyLevel.HIGH - else -> ContactFrequencyLevel.LOW - }, + contactFrequency = contactFrequencyEntity.toModel(), + birthday = birthday, + anniversaryList = anniversaryEntityList.map { it.toModel() }, + memo = memo, + phone = phone, + lastContactAt = lastContactAt, + ) + +fun ContactFrequencyEntity.toModel(): ContactFrequency = + ContactFrequency( + contactWeek = contactWeek, + dayOfWeek = dayOfWeek, + ) + +fun AnniversaryEntity.toModel(): Anniversary = + Anniversary( + id = id, + title = title, + date = date, + ) + +fun Friend.toRequest(): FriendRequest = + FriendRequest( + name = name, + relation = relation, + contactFrequency = contactFrequency.toRequest(), + birthday = birthday, + anniversaryList = anniversaryList.map { it.toRequest() }, + memo = memo, + phone = phone, + ) + +fun ContactFrequency.toRequest(): ContactFrequencyRequest = + ContactFrequencyRequest( + contactWeek = contactWeek, + dayOfWeek = dayOfWeek, + ) + +fun Anniversary.toRequest(): AnniversaryRequest = + AnniversaryRequest( + id = id, + title = title, + date = date, ) diff --git a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendRecordMapper.kt b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendRecordMapper.kt new file mode 100644 index 00000000..343e65b9 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendRecordMapper.kt @@ -0,0 +1,10 @@ +package com.alarmy.near.data.mapper + +import com.alarmy.near.model.FriendRecord +import com.alarmy.near.network.response.FriendRecordEntity + +fun FriendRecordEntity.toModel(): FriendRecord = + FriendRecord( + isChecked = isChecked, + createdAt = createdAt, + ) diff --git a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt new file mode 100644 index 00000000..c6468ee0 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt @@ -0,0 +1,21 @@ +package com.alarmy.near.data.mapper + +import com.alarmy.near.model.ContactFrequencyLevel +import com.alarmy.near.model.FriendSummary +import com.alarmy.near.network.response.FriendSummaryEntity + +fun FriendSummaryEntity.toModel(): FriendSummary = + FriendSummary( + id = friendId, + name = name, + profileImageUrl = imageUrl, + lastContactedAt = lastContactAt, + isContacted = true, + contactFrequencyLevel = + when (checkRate) { + in 0..29 -> ContactFrequencyLevel.LOW + in 30..69 -> ContactFrequencyLevel.MIDDLE + in 70..100 -> ContactFrequencyLevel.HIGH + else -> ContactFrequencyLevel.LOW + }, + ) diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt index 61ae63e7..8449b415 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,6 +1,9 @@ package com.alarmy.near.data.repository 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 import com.alarmy.near.model.monthly.MonthlyFriend import com.alarmy.near.network.service.FriendService @@ -30,4 +33,34 @@ class DefaultFriendRepository }, ) } + + override fun fetchFriendById(friendId: String): Flow = + flow { + emit(friendService.fetchFriendById(friendId).toModel()) + } + + override fun updateFriend( + friendId: String, + friend: Friend, + ): Flow = + flow { + emit(friendService.updateFriend(friendId, friend.toRequest()).toModel()) + } + + override fun deleteFriend(friendId: String): Flow = + flow { + friendService.deleteFriend(friendId) + emit(Unit) + } + + override fun fetchFriendRecord(friendId: String): Flow> = + flow { + emit(friendService.fetchFriendRecord(friendId).map { it.toModel() }) + } + + override fun recordContact(friendId: String): Flow = + flow { + val response = friendService.recordContact(friendId) + emit(response.message) // CommonMessageEntity.message 라고 가정 + } } diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/FriendRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/FriendRepository.kt index 425654fe..15c97570 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/FriendRepository.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/FriendRepository.kt @@ -1,5 +1,7 @@ package com.alarmy.near.data.repository +import com.alarmy.near.model.Friend +import com.alarmy.near.model.FriendRecord import com.alarmy.near.model.FriendSummary import com.alarmy.near.model.monthly.MonthlyFriend import kotlinx.coroutines.flow.Flow @@ -8,4 +10,17 @@ interface FriendRepository { fun fetchFriends(): Flow> fun fetchMonthlyFriends(): Flow> + + fun fetchFriendById(friendId: String): Flow + + fun updateFriend( + friendId: String, + friend: Friend, + ): Flow + + fun deleteFriend(friendId: String): Flow + + fun fetchFriendRecord(friendId: String): Flow> + + fun recordContact(friendId: String): Flow } diff --git a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt new file mode 100644 index 00000000..1660f7ad --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt @@ -0,0 +1,25 @@ +package com.alarmy.near.model + +data class Friend( + val friendId: String, + val imageUrl: String, + val relation: String, + val name: String, + val contactFrequency: ContactFrequency, + val birthday: String?, + val anniversaryList: List, + val memo: String?, + val phone: String?, + val lastContactAt: String?, +) + +data class ContactFrequency( + val contactWeek: String, + val dayOfWeek: String, +) + +data class Anniversary( + val id: Int, + val title: String, + val date: String, +) diff --git a/Near/app/src/main/java/com/alarmy/near/model/FriendRecord.kt b/Near/app/src/main/java/com/alarmy/near/model/FriendRecord.kt new file mode 100644 index 00000000..a7754e22 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/model/FriendRecord.kt @@ -0,0 +1,6 @@ +package com.alarmy.near.model + +data class FriendRecord( + val isChecked: Boolean, + val createdAt: String, +) From 2891d1d2084a94db453975dab975b2c00566256a Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 24 Aug 2025 18:15:49 +0900 Subject: [PATCH 066/100] =?UTF-8?q?refactor:=20friendSummary=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EC=84=B8=EB=B6=80=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt | 4 ++-- .../alarmy/near/data/repository/DefaultFriendRepository.kt | 2 +- .../java/com/alarmy/near/data/repository/FriendRepository.kt | 2 +- .../near/model/{ => friendsummary}/ContactFrequencyLevel.kt | 2 +- .../alarmy/near/model/{ => friendsummary}/FriendSummary.kt | 2 +- .../com/alarmy/near/presentation/feature/home/HomeScreen.kt | 4 ++-- .../alarmy/near/presentation/feature/home/HomeViewModel.kt | 2 +- .../near/presentation/feature/home/component/ContactItem.kt | 4 ++-- .../near/presentation/feature/home/component/MyContacts.kt | 4 ++-- 9 files changed, 13 insertions(+), 13 deletions(-) rename Near/app/src/main/java/com/alarmy/near/model/{ => friendsummary}/ContactFrequencyLevel.kt (61%) rename Near/app/src/main/java/com/alarmy/near/model/{ => friendsummary}/FriendSummary.kt (86%) diff --git a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt index c6468ee0..07450b6a 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt @@ -1,7 +1,7 @@ package com.alarmy.near.data.mapper -import com.alarmy.near.model.ContactFrequencyLevel -import com.alarmy.near.model.FriendSummary +import com.alarmy.near.model.friendsummary.ContactFrequencyLevel +import com.alarmy.near.model.friendsummary.FriendSummary import com.alarmy.near.network.response.FriendSummaryEntity fun FriendSummaryEntity.toModel(): FriendSummary = 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 8449b415..9d5ff99f 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 @@ -4,7 +4,7 @@ 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 +import com.alarmy.near.model.friendsummary.FriendSummary import com.alarmy.near.model.monthly.MonthlyFriend import com.alarmy.near.network.service.FriendService import kotlinx.coroutines.flow.Flow 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 15c97570..81e6761b 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/FriendRepository.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/FriendRepository.kt @@ -2,7 +2,7 @@ package com.alarmy.near.data.repository import com.alarmy.near.model.Friend import com.alarmy.near.model.FriendRecord -import com.alarmy.near.model.FriendSummary +import com.alarmy.near.model.friendsummary.FriendSummary import com.alarmy.near.model.monthly.MonthlyFriend import kotlinx.coroutines.flow.Flow diff --git a/Near/app/src/main/java/com/alarmy/near/model/ContactFrequencyLevel.kt b/Near/app/src/main/java/com/alarmy/near/model/friendsummary/ContactFrequencyLevel.kt similarity index 61% rename from Near/app/src/main/java/com/alarmy/near/model/ContactFrequencyLevel.kt rename to Near/app/src/main/java/com/alarmy/near/model/friendsummary/ContactFrequencyLevel.kt index 3d5c0df0..2da7665a 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/ContactFrequencyLevel.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/friendsummary/ContactFrequencyLevel.kt @@ -1,4 +1,4 @@ -package com.alarmy.near.model +package com.alarmy.near.model.friendsummary enum class ContactFrequencyLevel { LOW, diff --git a/Near/app/src/main/java/com/alarmy/near/model/FriendSummary.kt b/Near/app/src/main/java/com/alarmy/near/model/friendsummary/FriendSummary.kt similarity index 86% rename from Near/app/src/main/java/com/alarmy/near/model/FriendSummary.kt rename to Near/app/src/main/java/com/alarmy/near/model/friendsummary/FriendSummary.kt index b0fddf88..7d5799e3 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/FriendSummary.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/friendsummary/FriendSummary.kt @@ -1,4 +1,4 @@ -package com.alarmy.near.model +package com.alarmy.near.model.friendsummary import androidx.compose.runtime.Immutable 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 0f16dcaf..970753ea 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 @@ -52,8 +52,8 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R -import com.alarmy.near.model.ContactFrequencyLevel -import com.alarmy.near.model.FriendSummary +import com.alarmy.near.model.friendsummary.ContactFrequencyLevel +import com.alarmy.near.model.friendsummary.FriendSummary import com.alarmy.near.model.monthly.MonthlyFriend import com.alarmy.near.model.monthly.MonthlyFriendType import com.alarmy.near.presentation.feature.home.component.MyContacts diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeViewModel.kt index 899c1503..c8a4ccfe 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeViewModel.kt @@ -3,7 +3,7 @@ package com.alarmy.near.presentation.feature.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.alarmy.near.data.repository.FriendRepository -import com.alarmy.near.model.FriendSummary +import com.alarmy.near.model.friendsummary.FriendSummary import com.alarmy.near.model.monthly.MonthlyFriend import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt index ed45d6a9..892176ff 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt @@ -19,8 +19,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.alarmy.near.R -import com.alarmy.near.model.ContactFrequencyLevel -import com.alarmy.near.model.FriendSummary +import com.alarmy.near.model.friendsummary.ContactFrequencyLevel +import com.alarmy.near.model.friendsummary.FriendSummary import com.alarmy.near.presentation.ui.extension.onNoRippleClick import com.alarmy.near.presentation.ui.theme.NearTheme diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt index 1376552e..3e1ac181 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt @@ -17,8 +17,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.alarmy.near.model.ContactFrequencyLevel -import com.alarmy.near.model.FriendSummary +import com.alarmy.near.model.friendsummary.ContactFrequencyLevel +import com.alarmy.near.model.friendsummary.FriendSummary import com.alarmy.near.presentation.ui.theme.NearTheme private const val OVERFLOW_WIDTH_OF_CONTACT_ITEM_BY_NAME_TEXT = 34 From 5119ed8296b80ec05eac8daba4adc43ec4acaa79 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 24 Aug 2025 18:28:09 +0900 Subject: [PATCH 067/100] =?UTF-8?q?feat:=20friendProfileScreen=20=EC=83=81?= =?UTF-8?q?=EB=8B=A8=20=EC=83=81=ED=83=9C=EB=B0=94=20=ED=8C=A8=EB=94=A9=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../friendprofile/FriendProfileScreen.kt | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index 95e1a2d5..7df03b63 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -12,11 +12,13 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentSize @@ -37,6 +39,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -45,6 +48,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import com.alarmy.near.R import com.alarmy.near.presentation.feature.friendprofile.component.CallButton import com.alarmy.near.presentation.feature.friendprofile.component.MessageButton @@ -55,6 +59,7 @@ import com.alarmy.near.presentation.ui.theme.NearTheme @Composable fun FriendProfileRoute( + viewModel: FriendProfileViewModel = hiltViewModel(), onShowErrorSnackBar: (throwable: Throwable?) -> Unit, onClickBackButton: () -> Unit = {}, ) { @@ -68,9 +73,15 @@ fun FriendProfileScreen( modifier: Modifier = Modifier, onClickBackButton: () -> Unit = {}, ) { - // TODO Home 머지시 상단 패딩 + status 색상 변경 + val density = LocalDensity.current + val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } val currentTabPosition = remember { mutableIntStateOf(0) } - Box(modifier = modifier.padding(bottom = 24.dp)) { + Box( + modifier = + modifier + .background(NearTheme.colors.WHITE_FFFFFF) + .padding(top = statusBarHeightDp, bottom = 24.dp), + ) { Column( modifier = Modifier @@ -83,7 +94,10 @@ fun FriendProfileScreen( onClickBackButton = onClickBackButton, menuButton = { Image( - modifier = Modifier.onNoRippleClick(onClick = {}).padding(end = 20.dp), + modifier = + Modifier + .onNoRippleClick(onClick = {}) + .padding(end = 20.dp), painter = painterResource(R.drawable.ic_32_menu), contentDescription = stringResource(R.string.common_menu_button_description), ) From 77ced7ccf794d3370d7e92f6a9daf52213cd033e Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 24 Aug 2025 18:43:09 +0900 Subject: [PATCH 068/100] =?UTF-8?q?chore:=20string=20res=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Near/app/src/main/res/values/strings.xml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml index f6daf58a..3c2cb474 100644 --- a/Near/app/src/main/res/values/strings.xml +++ b/Near/app/src/main/res/values/strings.xml @@ -22,6 +22,7 @@ 연락처 추가 가까워 지고 싶은 사람을\n추가해보세요. 사람 추가 + 사람 추가 전화걸기 @@ -38,10 +39,12 @@ 친구 가족 지인 + + 매일 매주 2주 매달 6개월 - 사람 추가 + 프로필 상세 From 7bfb1a80d01a79e9f13b2b8bb1ae4c262dae97ba Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 24 Aug 2025 18:43:21 +0900 Subject: [PATCH 069/100] =?UTF-8?q?feat:=20dropdown=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../friendprofile/FriendProfileScreen.kt | 58 ++++++++++++++++--- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index 7df03b63..a7239d58 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -25,6 +25,8 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Surface import androidx.compose.material3.Tab @@ -35,6 +37,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -76,6 +79,7 @@ fun FriendProfileScreen( val density = LocalDensity.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } val currentTabPosition = remember { mutableIntStateOf(0) } + val dropdownState = remember { mutableStateOf(false) } Box( modifier = modifier @@ -90,19 +94,55 @@ fun FriendProfileScreen( .background(NearTheme.colors.WHITE_FFFFFF), ) { NearTopAppbar( - title = "프로필 상세", + title = stringResource(R.string.friend_profile_title), onClickBackButton = onClickBackButton, menuButton = { - Image( - modifier = - Modifier - .onNoRippleClick(onClick = {}) - .padding(end = 20.dp), - painter = painterResource(R.drawable.ic_32_menu), - contentDescription = stringResource(R.string.common_menu_button_description), - ) + Column(modifier = Modifier.padding(end = 20.dp)) { + Image( + modifier = + Modifier + .onNoRippleClick(onClick = { + dropdownState.value = true + }), + painter = painterResource(R.drawable.ic_32_menu), + contentDescription = stringResource(R.string.common_menu_button_description), + ) + DropdownMenu( + modifier = Modifier.background(color = NearTheme.colors.WHITE_FFFFFF), + expanded = dropdownState.value, + shape = RoundedCornerShape(12.dp), + onDismissRequest = { dropdownState.value = false }, + ) { + DropdownMenuItem( + onClick = { +// onUpdateFriend() + dropdownState.value = false + }, + text = { + Text( + "수정", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + }, + ) + DropdownMenuItem( + onClick = { + dropdownState.value = false + }, + text = { + Text( + "삭제", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + }, + ) + } + } }, ) + Spacer(modifier = Modifier.height(18.dp)) Row( modifier = From 4f5e7af7865e9fa051145e4f5760d70d6b02469b Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Mon, 25 Aug 2025 00:55:06 +0900 Subject: [PATCH 070/100] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alarmy/near/data/mapper/FriendMapper.kt | 4 +- .../repository/DefaultFriendRepository.kt | 6 +- .../main/java/com/alarmy/near/model/Friend.kt | 2 +- .../near/network/response/FriendEntity.kt | 6 +- .../friendprofile/FriendProfileScreen.kt | 415 ++++++++++-------- .../friendprofile/FriendProfileViewModel.kt | 53 +++ .../navigation/FriendProfileNavigation.kt | 3 + .../friendprofile/uistate/FriendState.kt | 15 + 8 files changed, 314 insertions(+), 190 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendState.kt 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 0aab0634..3d3cac86 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 @@ -16,9 +16,9 @@ fun FriendEntity.toModel(): Friend = imageUrl = imageUrl, relation = relation, name = name, - contactFrequency = contactFrequencyEntity.toModel(), + contactFrequency = contactFrequency.toModel(), birthday = birthday, - anniversaryList = anniversaryEntityList.map { it.toModel() }, + anniversaryList = anniversaryList.map { it.toModel() }, memo = memo, phone = phone, lastContactAt = lastContactAt, 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 9d5ff99f..cbb7bfd9 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,5 +1,6 @@ package com.alarmy.near.data.repository +import android.util.Log import com.alarmy.near.data.mapper.toModel import com.alarmy.near.data.mapper.toRequest import com.alarmy.near.model.Friend @@ -27,6 +28,7 @@ class DefaultFriendRepository override fun fetchMonthlyFriends(): Flow> = flow { + Log.d("test", "repository") emit( friendService.fetchMonthlyFriends().map { it.toModel() @@ -61,6 +63,6 @@ class DefaultFriendRepository override fun recordContact(friendId: String): Flow = flow { val response = friendService.recordContact(friendId) - emit(response.message) // CommonMessageEntity.message 라고 가정 - } + emit(response.message) // CommonMessageEntity.message 라고 가정 + } } diff --git a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt index 1660f7ad..f23b6ad4 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt @@ -2,7 +2,7 @@ package com.alarmy.near.model data class Friend( val friendId: String, - val imageUrl: String, + val imageUrl: String?, val relation: String, val name: String, val contactFrequency: ContactFrequency, diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt b/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt index 3862ebd7..16e38353 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt @@ -5,12 +5,12 @@ import kotlinx.serialization.Serializable @Serializable data class FriendEntity( val friendId: String, - val imageUrl: String, + val imageUrl: String?, val relation: String, val name: String, - val contactFrequencyEntity: ContactFrequencyEntity, + val contactFrequency: ContactFrequencyEntity, val birthday: String?, - val anniversaryEntityList: List, + val anniversaryList: List, val memo: String?, val phone: String?, val lastContactAt: String?, diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index a7239d58..73ed4b9d 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider @@ -52,9 +53,13 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R +import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.Friend import com.alarmy.near.presentation.feature.friendprofile.component.CallButton import com.alarmy.near.presentation.feature.friendprofile.component.MessageButton +import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendState import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar import com.alarmy.near.presentation.ui.component.button.NearSolidTypeButton import com.alarmy.near.presentation.ui.extension.onNoRippleClick @@ -65,16 +70,22 @@ fun FriendProfileRoute( viewModel: FriendProfileViewModel = hiltViewModel(), onShowErrorSnackBar: (throwable: Throwable?) -> Unit, onClickBackButton: () -> Unit = {}, + onEditFriendInfo: (Friend) -> Unit = {}, ) { + val friendState = viewModel.friendFlow.collectAsStateWithLifecycle() FriendProfileScreen( + friendState = friendState.value, onClickBackButton = onClickBackButton, + onEditFriendInfo = onEditFriendInfo, ) } @Composable fun FriendProfileScreen( modifier: Modifier = Modifier, + friendState: FriendState, onClickBackButton: () -> Unit = {}, + onEditFriendInfo: (Friend) -> Unit = {}, ) { val density = LocalDensity.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } @@ -86,207 +97,227 @@ fun FriendProfileScreen( .background(NearTheme.colors.WHITE_FFFFFF) .padding(top = statusBarHeightDp, bottom = 24.dp), ) { - Column( - modifier = - Modifier - .align(Alignment.TopStart) - .fillMaxSize() - .background(NearTheme.colors.WHITE_FFFFFF), - ) { - NearTopAppbar( - title = stringResource(R.string.friend_profile_title), - onClickBackButton = onClickBackButton, - menuButton = { - Column(modifier = Modifier.padding(end = 20.dp)) { - Image( + when (friendState) { + is FriendState.Success -> { + val friend = friendState.friend + Column( + modifier = + Modifier + .align(Alignment.TopStart) + .fillMaxSize() + .background(NearTheme.colors.WHITE_FFFFFF), + ) { + NearTopAppbar( + title = stringResource(R.string.friend_profile_title), + onClickBackButton = onClickBackButton, + menuButton = { + Column(modifier = Modifier.padding(end = 20.dp)) { + Image( + modifier = + Modifier + .onNoRippleClick(onClick = { + dropdownState.value = true + }), + painter = painterResource(R.drawable.ic_32_menu), + contentDescription = stringResource(R.string.common_menu_button_description), + ) + DropdownMenu( + modifier = Modifier.background(color = NearTheme.colors.WHITE_FFFFFF), + expanded = dropdownState.value, + shape = RoundedCornerShape(12.dp), + onDismissRequest = { dropdownState.value = false }, + ) { + DropdownMenuItem( + onClick = { + onEditFriendInfo(friend) + dropdownState.value = false + }, + text = { + Text( + "수정", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + }, + ) + DropdownMenuItem( + onClick = { + dropdownState.value = false + }, + text = { + Text( + "삭제", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + }, + ) + } + } + }, + ) + + Spacer(modifier = Modifier.height(18.dp)) + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( modifier = - Modifier - .onNoRippleClick(onClick = { - dropdownState.value = true - }), - painter = painterResource(R.drawable.ic_32_menu), - contentDescription = stringResource(R.string.common_menu_button_description), - ) - DropdownMenu( - modifier = Modifier.background(color = NearTheme.colors.WHITE_FFFFFF), - expanded = dropdownState.value, - shape = RoundedCornerShape(12.dp), - onDismissRequest = { dropdownState.value = false }, + Modifier, ) { - DropdownMenuItem( - onClick = { -// onUpdateFriend() - dropdownState.value = false - }, - text = { - Text( - "수정", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - }, + Image( + modifier = Modifier.align(Alignment.Center), + painter = painterResource(R.drawable.img_80_user1), + contentDescription = null, ) - DropdownMenuItem( - onClick = { - dropdownState.value = false - }, - text = { - Text( - "삭제", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - }, + Image( + modifier = + Modifier + .align(Alignment.TopEnd) + .offset(x = 2.dp, y = (-2).dp), + painter = painterResource(R.drawable.ic_visual_24_emoji_100), + contentDescription = null, + ) + } + Spacer(modifier = Modifier.width(24.dp)) + Column { + Text( + modifier = Modifier.widthIn(max = 145.dp), + text = friend.name, + style = NearTheme.typography.B1_16_BOLD, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "3월 22일 더 가까워졌어요", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLUE01_5AA2E9, ) } } - }, - ) - - Spacer(modifier = Modifier.height(18.dp)) - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = - Modifier, - ) { - Image( - modifier = Modifier.align(Alignment.Center), - painter = painterResource(R.drawable.img_80_user1), - contentDescription = null, - ) - Image( + Spacer(modifier = Modifier.height(24.dp)) + Row( modifier = Modifier - .align(Alignment.TopEnd) - .offset(x = 2.dp, y = (-2).dp), - painter = painterResource(R.drawable.ic_visual_24_emoji_100), - contentDescription = null, - ) - } - Spacer(modifier = Modifier.width(24.dp)) - Column { - Text( - modifier = Modifier.widthIn(max = 145.dp), - text = "일이삼사오육칠", - style = NearTheme.typography.B1_16_BOLD, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "3월 22일 더 가까워졌어요", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLUE01_5AA2E9, - ) - } - } - Spacer(modifier = Modifier.height(24.dp)) - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - ) { - CallButton(modifier = Modifier.weight(1f), onClick = {}) - Spacer(modifier = Modifier.width(7.dp)) - MessageButton(Modifier.weight(1f), onClick = {}) - } - Spacer(modifier = Modifier.height(24.dp)) - TabRow( - modifier = - Modifier - .padding(horizontal = 25.dp) - .width(170.dp), - containerColor = NearTheme.colors.WHITE_FFFFFF, - selectedTabIndex = 0, - divider = {}, - indicator = { - TabRowDefaults.SecondaryIndicator( + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) { + CallButton(modifier = Modifier.weight(1f), onClick = {}) + Spacer(modifier = Modifier.width(7.dp)) + MessageButton(Modifier.weight(1f), onClick = {}) + } + Spacer(modifier = Modifier.height(24.dp)) + TabRow( modifier = Modifier - .customTabIndicatorOffset( - it[currentTabPosition.intValue], - 80.dp, - ), // 넓이, 애니메이션 지정 - // 모양 지정 - height = 3.dp, - color = NearTheme.colors.BLUE01_5AA2E9, - ) - }, - ) { - Tab( - modifier = - Modifier - .width(85.dp) - .height(50.dp), - selected = true, - onClick = { - currentTabPosition.intValue = 0 - }, - ) { + .padding(horizontal = 25.dp) + .width(170.dp), + containerColor = NearTheme.colors.WHITE_FFFFFF, + selectedTabIndex = 0, + divider = {}, + indicator = { + TabRowDefaults.SecondaryIndicator( + modifier = + Modifier + .customTabIndicatorOffset( + it[currentTabPosition.intValue], + 80.dp, + ), // 넓이, 애니메이션 지정 + // 모양 지정 + height = 3.dp, + color = NearTheme.colors.BLUE01_5AA2E9, + ) + }, + ) { + Tab( + modifier = + Modifier + .width(85.dp) + .height(50.dp), + selected = true, + onClick = { + currentTabPosition.intValue = 0 + }, + ) { + if (currentTabPosition.intValue == 0) { + Text( + text = stringResource(R.string.friend_profile_tab_text_profile), + style = NearTheme.typography.B2_14_BOLD, + color = NearTheme.colors.BLACK_1A1A1A, + ) + } else { + Text( + text = stringResource(R.string.friend_profile_tab_text_profile), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY02_B7B7B7, + ) + } + } + Tab( + modifier = + Modifier + .width(85.dp) + .height(50.dp), + selected = true, + onClick = { + currentTabPosition.intValue = 1 + }, + ) { + if (currentTabPosition.intValue == 1) { + Text( + text = stringResource(R.string.friend_profile_tab_text_record), + style = NearTheme.typography.B2_14_BOLD, + color = NearTheme.colors.BLACK_1A1A1A, + ) + } else { + Text( + text = stringResource(R.string.friend_profile_tab_text_record), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY02_B7B7B7, + ) + } + } + } + HorizontalDivider(thickness = 1.dp, color = NearTheme.colors.GRAY03_EBEBEB) if (currentTabPosition.intValue == 0) { - Text( - text = stringResource(R.string.friend_profile_tab_text_profile), - style = NearTheme.typography.B2_14_BOLD, - color = NearTheme.colors.BLACK_1A1A1A, - ) + ProfileTab() } else { - Text( - text = stringResource(R.string.friend_profile_tab_text_profile), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY02_B7B7B7, - ) + RecordTab() } } - Tab( + NearSolidTypeButton( modifier = Modifier - .width(85.dp) - .height(50.dp), - selected = true, - onClick = { - currentTabPosition.intValue = 1 - }, - ) { - if (currentTabPosition.intValue == 1) { - Text( - text = stringResource(R.string.friend_profile_tab_text_record), - style = NearTheme.typography.B2_14_BOLD, - color = NearTheme.colors.BLACK_1A1A1A, - ) - } else { - Text( - text = stringResource(R.string.friend_profile_tab_text_record), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY02_B7B7B7, - ) - } + .fillMaxWidth() + .padding(horizontal = 20.dp) + .align(Alignment.BottomCenter), + contentPadding = PaddingValues(vertical = 17.dp), + enabled = true, + onClick = {}, + text = stringResource(R.string.friend_profile_record_button_text), + ) + } + + is FriendState.Loading -> { + Box(modifier = Modifier.fillMaxSize()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } - HorizontalDivider(thickness = 1.dp, color = NearTheme.colors.GRAY03_EBEBEB) - if (currentTabPosition.intValue == 0) { - ProfileTab() - } else { - RecordTab() + + is FriendState.Error -> { + Box(modifier = Modifier.fillMaxSize()) { + Text( + modifier = Modifier.align(Alignment.Center), + text = "프로필 정보를 불러오는데 실패했습니다.", + ) + } } } - NearSolidTypeButton( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - .align(Alignment.BottomCenter), - contentPadding = PaddingValues(vertical = 17.dp), - enabled = true, - onClick = {}, - text = stringResource(R.string.friend_profile_record_button_text), - ) } } @@ -513,6 +544,26 @@ fun Modifier.customTabIndicatorOffset( @Composable fun FriendProfileScreenPreview() { NearTheme { - FriendProfileScreen() + FriendProfileScreen( + friendState = + FriendState.Success( + Friend( + friendId = "adfaggasf", + imageUrl = "", + relation = "FRIEND", + name = "", + contactFrequency = + ContactFrequency( + contactWeek = "EVERY_DAY", + dayOfWeek = "MONDAY", + ), + birthday = "1998-11-13", + anniversaryList = listOf(), + memo = "", + phone = "", + lastContactAt = "", + ), + ), + ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt new file mode 100644 index 00000000..07fc49e6 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt @@ -0,0 +1,53 @@ +package com.alarmy.near.presentation.feature.friendprofile + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.alarmy.near.data.repository.FriendRepository +import com.alarmy.near.presentation.feature.friendprofile.navigation.RouteFriendProfile +import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import javax.inject.Inject + +@HiltViewModel +class FriendProfileViewModel + @Inject + constructor( + savedStateHandle: SavedStateHandle, + private val friendRepository: FriendRepository, + ) : ViewModel() { + private val friendId: String = savedStateHandle.toRoute().friendId + private val _errorEvent = Channel() + val errorEvent = _errorEvent.receiveAsFlow() + private val _friendFlow: MutableStateFlow = MutableStateFlow(FriendState.Loading) + val friendFlow: StateFlow = _friendFlow.asStateFlow() + + init { + fetchFriend() + } + + fun fetchFriend() { + friendRepository + .fetchFriendById(friendId) + .onEach { friend -> + _friendFlow.value = FriendState.Success(friend) + }.catch { error -> + Log.d("test1", error.message.toString()) + _friendFlow.value = FriendState.Error("데이터를 가져오는데 실패했습니다.") + _errorEvent.send(error) // UI에서 단발성 이벤트로도 쓸 수 있음 + }.launchIn(viewModelScope) + } + + fun deleteFriend() { + } + } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt index c3b4608a..7dd00f59 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt @@ -4,6 +4,7 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable +import com.alarmy.near.model.Friend import com.alarmy.near.presentation.feature.friendprofile.FriendProfileRoute import kotlinx.serialization.Serializable @@ -22,11 +23,13 @@ fun NavController.navigateToFriendProfile( fun NavGraphBuilder.friendProfileNavGraph( onShowErrorSnackBar: (throwable: Throwable?) -> Unit, onClickBackButton: () -> Unit, + onEditFriendInfo: (Friend) -> Unit = {}, ) { composable { backStackEntry -> FriendProfileRoute( onShowErrorSnackBar = onShowErrorSnackBar, onClickBackButton = onClickBackButton, + onEditFriendInfo = onEditFriendInfo, ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendState.kt new file mode 100644 index 00000000..b9b089a7 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendState.kt @@ -0,0 +1,15 @@ +package com.alarmy.near.presentation.feature.friendprofile.uistate + +import com.alarmy.near.model.Friend + +sealed interface FriendState { + object Loading : FriendState + + data class Success( + val friend: Friend, + ) : FriendState + + data class Error( + val errorMessage: String, + ) : FriendState +} From ccd2f941b2330e43cbe6f66f8f9074a584a2f5b7 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 7 Sep 2025 16:38:02 +0900 Subject: [PATCH 071/100] =?UTF-8?q?refactor:=20conflict=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../friendprofile/FriendProfileScreen.kt | 26 +++++++++++- .../navigation/FriendProfileNavigation.kt | 4 ++ .../presentation/feature/main/NearNavHost.kt | 42 +++++++++++-------- 3 files changed, 52 insertions(+), 20 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index 73ed4b9d..fb957f58 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -71,12 +71,16 @@ fun FriendProfileRoute( onShowErrorSnackBar: (throwable: Throwable?) -> Unit, onClickBackButton: () -> Unit = {}, onEditFriendInfo: (Friend) -> Unit = {}, + onClickCallButton: (phoneNumber: String) -> Unit = {}, + onClickMessageButton: (phoneNumber: String) -> Unit = {}, ) { val friendState = viewModel.friendFlow.collectAsStateWithLifecycle() FriendProfileScreen( friendState = friendState.value, onClickBackButton = onClickBackButton, onEditFriendInfo = onEditFriendInfo, + onClickCallButton = onClickCallButton, + onClickMessageButton = onClickMessageButton, ) } @@ -86,6 +90,8 @@ fun FriendProfileScreen( friendState: FriendState, onClickBackButton: () -> Unit = {}, onEditFriendInfo: (Friend) -> Unit = {}, + onClickCallButton: (phoneNumber: String) -> Unit = {}, + onClickMessageButton: (phoneNumber: String) -> Unit = {}, ) { val density = LocalDensity.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } @@ -207,9 +213,25 @@ fun FriendProfileScreen( .fillMaxWidth() .padding(horizontal = 20.dp), ) { - CallButton(modifier = Modifier.weight(1f), onClick = {}) + CallButton( + modifier = Modifier.weight(1f), + enabled = !friend.phone.isNullOrBlank(), + onClick = { + friend.phone?.let { + onClickCallButton(friend.phone) + } + }, + ) Spacer(modifier = Modifier.width(7.dp)) - MessageButton(Modifier.weight(1f), onClick = {}) + MessageButton( + Modifier.weight(1f), + enabled = !friend.phone.isNullOrBlank(), + onClick = { + friend.phone?.let { + onClickMessageButton(friend.phone) + } + }, + ) } Spacer(modifier = Modifier.height(24.dp)) TabRow( diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt index 7dd00f59..c28df80c 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt @@ -24,12 +24,16 @@ fun NavGraphBuilder.friendProfileNavGraph( onShowErrorSnackBar: (throwable: Throwable?) -> Unit, onClickBackButton: () -> Unit, onEditFriendInfo: (Friend) -> Unit = {}, + onClickCallButton: (phoneNumber: String) -> Unit = {}, + onClickMessageButton: (phoneNumber: String) -> Unit = {}, ) { composable { backStackEntry -> FriendProfileRoute( onShowErrorSnackBar = onShowErrorSnackBar, onClickBackButton = onClickBackButton, onEditFriendInfo = onEditFriendInfo, + onClickCallButton = onClickCallButton, + onClickMessageButton = onClickMessageButton, ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt index 3b7ad4b9..ce363dd4 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt @@ -1,7 +1,10 @@ package com.alarmy.near.presentation.feature.main +import android.content.Intent import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.core.net.toUri import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import com.alarmy.near.presentation.feature.friendprofile.navigation.friendProfileNavGraph @@ -19,14 +22,33 @@ internal fun NearNavHost( navController: NavHostController, onShowSnackbar: (Throwable?) -> Unit = { _ -> }, ) { + val context = LocalContext.current /* * 화면 이동 및 구성을 위한 컴포저블 함수입니다. * */ NavHost( modifier = modifier, navController = navController, - startDestination = RouteLogin, + startDestination = RouteHome, ) { + friendProfileNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = { + navController.popBackStack() + }, onClickCallButton = { phoneNumber -> + val intent = + Intent(Intent.ACTION_DIAL).apply { + data = "tel:$phoneNumber".toUri() + } + context.startActivity(intent) + }, onClickMessageButton = { phoneNumber -> + val intent = + Intent(Intent.ACTION_VIEW).apply { + data = "sms:$phoneNumber".toUri() + } + context.startActivity(intent) + }) + friendProfileEditorNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = { + navController.popBackStack() + }) // 로그인 화면 NavGraph loginNavGraph( onShowErrorSnackBar = onShowSnackbar, @@ -38,7 +60,7 @@ internal fun NearNavHost( ) } ) - + // 홈 화면 NavGraph homeNavGraph( onShowErrorSnackBar = onShowSnackbar, @@ -49,21 +71,5 @@ internal fun NearNavHost( onAlarmClick = {}, onAddContactClick = {}, ) - - // 친구 프로필 화면 NavGraph - friendProfileNavGraph( - onShowErrorSnackBar = onShowSnackbar, - onClickBackButton = { - navController.popBackStack() - } - ) - - // 친구 프로필 편집 화면 NavGraph - friendProfileEditorNavGraph( - onShowErrorSnackBar = onShowSnackbar, - onClickBackButton = { - navController.popBackStack() - } - ) } } From bf10b44fe0fd561ed7a457b022c323b906631f71 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Mon, 25 Aug 2025 23:25:22 +0900 Subject: [PATCH 072/100] =?UTF-8?q?docs:=20res=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8A=94=20model=20TODO=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI Layer로 이동 --- Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt | 1 + .../main/java/com/alarmy/near/model/monthly/MonthlyFriendType.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt b/Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt index 0d39369d..e11e6d47 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt @@ -3,6 +3,7 @@ package com.alarmy.near.model import androidx.annotation.StringRes import com.alarmy.near.R +// TODO StringRes UI-Layer 이동 enum class ReminderInterval( @param:StringRes val labelRes: Int, ) { diff --git a/Near/app/src/main/java/com/alarmy/near/model/monthly/MonthlyFriendType.kt b/Near/app/src/main/java/com/alarmy/near/model/monthly/MonthlyFriendType.kt index 553d6939..0efc9fb0 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/monthly/MonthlyFriendType.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/monthly/MonthlyFriendType.kt @@ -3,6 +3,7 @@ package com.alarmy.near.model.monthly import androidx.annotation.DrawableRes import com.alarmy.near.R +// TODO Drawable Res UI-Layer 이동 enum class MonthlyFriendType( @param:DrawableRes val imageSrc: Int, ) { From 9e6673f23effc9ade1e50e86eb2f8fe2a7f6d11d Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Mon, 25 Aug 2025 23:31:16 +0900 Subject: [PATCH 073/100] =?UTF-8?q?refactor:=20remindInterval=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=99=80=20=EB=84=A4=EC=9D=B4=EB=B0=8D=EC=9D=84=20?= =?UTF-8?q?=EB=98=91=EA=B0=99=EC=9D=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/alarmy/near/data/mapper/FriendMapper.kt | 5 +++-- .../app/src/main/java/com/alarmy/near/model/Friend.kt | 2 +- .../java/com/alarmy/near/model/ReminderInterval.kt | 11 +++++------ .../feature/friendprofile/FriendProfileScreen.kt | 2 +- .../component/ReminderIntervalBottomSheet.kt | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) 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 3d3cac86..b76e7245 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 @@ -3,6 +3,7 @@ package com.alarmy.near.data.mapper import com.alarmy.near.model.Anniversary import com.alarmy.near.model.ContactFrequency import com.alarmy.near.model.Friend +import com.alarmy.near.model.ReminderInterval import com.alarmy.near.network.request.AnniversaryRequest import com.alarmy.near.network.request.ContactFrequencyRequest import com.alarmy.near.network.request.FriendRequest @@ -26,7 +27,7 @@ fun FriendEntity.toModel(): Friend = fun ContactFrequencyEntity.toModel(): ContactFrequency = ContactFrequency( - contactWeek = contactWeek, + reminderInterval = ReminderInterval.valueOf(contactWeek), dayOfWeek = dayOfWeek, ) @@ -50,7 +51,7 @@ fun Friend.toRequest(): FriendRequest = fun ContactFrequency.toRequest(): ContactFrequencyRequest = ContactFrequencyRequest( - contactWeek = contactWeek, + contactWeek = reminderInterval.toString(), dayOfWeek = dayOfWeek, ) diff --git a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt index f23b6ad4..ec372bc7 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt @@ -14,7 +14,7 @@ data class Friend( ) data class ContactFrequency( - val contactWeek: String, + val reminderInterval: ReminderInterval, val dayOfWeek: String, ) diff --git a/Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt b/Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt index e11e6d47..5bbd80f8 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt @@ -3,13 +3,12 @@ package com.alarmy.near.model import androidx.annotation.StringRes import com.alarmy.near.R -// TODO StringRes UI-Layer 이동 enum class ReminderInterval( @param:StringRes val labelRes: Int, ) { - DAILY(R.string.reminder_interval_daily), // 매일 - WEEKLY(R.string.reminder_interval_weekly), // 매주 - BIWEEKLY(R.string.reminder_interval_biweekly), // 2주 - MONTHLY(R.string.reminder_interval_monthly), // 매달 - SEMIANNUAL(R.string.reminder_interval_semiannual), // 6개월 + EVERY_DAY(R.string.reminder_interval_daily), + EVERY_WEEK(R.string.reminder_interval_weekly), + EVERY_TWO_WEEK(R.string.reminder_interval_biweekly), + EVERY_MONTH(R.string.reminder_interval_monthly), + EVERY_SIX_MONTH(R.string.reminder_interval_semiannual), } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index fb957f58..4c100d82 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -576,7 +576,7 @@ fun FriendProfileScreenPreview() { name = "", contactFrequency = ContactFrequency( - contactWeek = "EVERY_DAY", + reminderInterval = "EVERY_DAY", dayOfWeek = "MONDAY", ), birthday = "1998-11-13", diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/ReminderIntervalBottomSheet.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/ReminderIntervalBottomSheet.kt index b4f43b77..ce7902f1 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/ReminderIntervalBottomSheet.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/ReminderIntervalBottomSheet.kt @@ -44,7 +44,7 @@ import com.alarmy.near.presentation.ui.theme.NearTheme @Composable fun ReminderIntervalBottomSheet( modifier: Modifier = Modifier, - selectedReminderInterval: ReminderInterval = ReminderInterval.WEEKLY, + selectedReminderInterval: ReminderInterval = ReminderInterval.EVERY_WEEK, onSelectReminderInterval: (ReminderInterval) -> Unit = {}, sheetState: SheetState = rememberModalBottomSheetState( From 22997cdfc81ba368d3523ad3cf862e1b575c5e6f Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Mon, 25 Aug 2025 23:33:04 +0900 Subject: [PATCH 074/100] =?UTF-8?q?refactor:=20relation=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EC=9D=84=20enum=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/alarmy/near/data/mapper/FriendMapper.kt | 5 +++-- Near/app/src/main/java/com/alarmy/near/model/Friend.kt | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) 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 b76e7245..3e19c710 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 @@ -3,6 +3,7 @@ package com.alarmy.near.data.mapper import com.alarmy.near.model.Anniversary import com.alarmy.near.model.ContactFrequency import com.alarmy.near.model.Friend +import com.alarmy.near.model.Relation import com.alarmy.near.model.ReminderInterval import com.alarmy.near.network.request.AnniversaryRequest import com.alarmy.near.network.request.ContactFrequencyRequest @@ -15,7 +16,7 @@ fun FriendEntity.toModel(): Friend = Friend( friendId = friendId, imageUrl = imageUrl, - relation = relation, + relation = Relation.valueOf(relation), name = name, contactFrequency = contactFrequency.toModel(), birthday = birthday, @@ -41,7 +42,7 @@ fun AnniversaryEntity.toModel(): Anniversary = fun Friend.toRequest(): FriendRequest = FriendRequest( name = name, - relation = relation, + relation = relation.toString(), contactFrequency = contactFrequency.toRequest(), birthday = birthday, anniversaryList = anniversaryList.map { it.toRequest() }, diff --git a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt index ec372bc7..f4221203 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt @@ -3,7 +3,7 @@ package com.alarmy.near.model data class Friend( val friendId: String, val imageUrl: String?, - val relation: String, + val relation: Relation, val name: String, val contactFrequency: ContactFrequency, val birthday: String?, From 1c337d25ce50722abdae614d55a76bc19bbed643 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Tue, 26 Aug 2025 20:03:07 +0900 Subject: [PATCH 075/100] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/alarmy/near/model/Relation.kt | 13 +++-- .../friendprofile/FriendProfileScreen.kt | 56 +++++++++++++------ Near/app/src/main/res/values/strings.xml | 4 ++ 3 files changed, 53 insertions(+), 20 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/model/Relation.kt b/Near/app/src/main/java/com/alarmy/near/model/Relation.kt index e01a7a52..1c84a7ba 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/Relation.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/Relation.kt @@ -1,7 +1,12 @@ package com.alarmy.near.model -enum class Relation { - FRIEND, - FAMILY, - ACQUAINTANCE, +import androidx.annotation.StringRes +import com.alarmy.near.R + +enum class Relation( + @param:StringRes val resId: Int, +) { + FRIEND(R.string.relation_friend), + FAMILY(R.string.relation_family), + ACQUAINTANCE(R.string.relation_acquaintance), } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index 4c100d82..7ab26c44 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -57,6 +57,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R import com.alarmy.near.model.ContactFrequency import com.alarmy.near.model.Friend +import com.alarmy.near.model.Relation +import com.alarmy.near.model.ReminderInterval import com.alarmy.near.presentation.feature.friendprofile.component.CallButton import com.alarmy.near.presentation.feature.friendprofile.component.MessageButton import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendState @@ -64,6 +66,8 @@ import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar import com.alarmy.near.presentation.ui.component.button.NearSolidTypeButton import com.alarmy.near.presentation.ui.extension.onNoRippleClick import com.alarmy.near.presentation.ui.theme.NearTheme +import java.time.LocalDate +import java.time.format.DateTimeFormatter @Composable fun FriendProfileRoute( @@ -185,7 +189,7 @@ fun FriendProfileScreen( Modifier .align(Alignment.TopEnd) .offset(x = 2.dp, y = (-2).dp), - painter = painterResource(R.drawable.ic_visual_24_emoji_100), + painter = painterResource(R.drawable.ic_visual_24_emoji_0), contentDescription = null, ) } @@ -198,12 +202,18 @@ fun FriendProfileScreen( maxLines = 2, overflow = TextOverflow.Ellipsis, ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "3월 22일 더 가까워졌어요", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLUE01_5AA2E9, - ) + if (friend.lastContactAt != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = + stringResource( + R.string.friend_profile_last_contact_date_format, + friend.lastContactAt.lastContactFormat(), + ), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLUE01_5AA2E9, + ) + } } } Spacer(modifier = Modifier.height(24.dp)) @@ -307,7 +317,7 @@ fun FriendProfileScreen( } HorizontalDivider(thickness = 1.dp, color = NearTheme.colors.GRAY03_EBEBEB) if (currentTabPosition.intValue == 0) { - ProfileTab() + ProfileTab(friend = friend) } else { RecordTab() } @@ -344,31 +354,37 @@ fun FriendProfileScreen( } @Composable -private fun ProfileTab(modifier: Modifier = Modifier) { +private fun ProfileTab( + modifier: Modifier = Modifier, + friend: Friend, +) { Column(modifier = modifier) { Spacer(modifier = Modifier.height(32.dp)) ProfileDetailInfo( category = stringResource(R.string.friend_profile_info_category_relation), - content = "친구", + content = stringResource(friend.relation.resId), ) Spacer(modifier = Modifier.height(16.dp)) ProfileDetailInfo( category = stringResource(R.string.friend_profile_info_category_term_of_contact), - content = "2주", + content = stringResource(friend.contactFrequency.reminderInterval.labelRes), ) Spacer(modifier = Modifier.height(16.dp)) ProfileDetailInfo( category = stringResource(R.string.friend_profile_info_category_birthday), - content = "1996.03.21", + content = friend.birthday?.replace("-", ".") ?: "-", ) Spacer(modifier = Modifier.height(16.dp)) ProfileDetailInfo( category = stringResource(R.string.friend_profile_info_category_anniversary), - content = "결혼기념일 (2020.06.24)", + content = + friend.anniversaryList.joinToString(" ") { + "${it.title} (${it.date})" + }, ) Spacer(modifier = Modifier.height(16.dp)) ProfileMemoInfo( - content = null, + content = friend.memo, ) } } @@ -562,6 +578,14 @@ fun Modifier.customTabIndicatorOffset( .width(currentTabWidth) } +private fun String.lastContactFormat(): String { + val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + val outputFormatter = DateTimeFormatter.ofPattern("M월 d일") + + val date = LocalDate.parse(this, inputFormatter) + return date.format(outputFormatter) +} + @Preview(showBackground = true) @Composable fun FriendProfileScreenPreview() { @@ -572,11 +596,11 @@ fun FriendProfileScreenPreview() { Friend( friendId = "adfaggasf", imageUrl = "", - relation = "FRIEND", + relation = Relation.FRIEND, name = "", contactFrequency = ContactFrequency( - reminderInterval = "EVERY_DAY", + reminderInterval = ReminderInterval.EVERY_TWO_WEEK, dayOfWeek = "MONDAY", ), birthday = "1998-11-13", diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml index 3c2cb474..d7e46a5b 100644 --- a/Near/app/src/main/res/values/strings.xml +++ b/Near/app/src/main/res/values/strings.xml @@ -47,4 +47,8 @@ 매달 6개월 프로필 상세 + 친구 + 가족 + 지인 + %1$s 더 가까워졌어요 From 46fd87f0d7a535ac95accac6cba1e8dcafc9147d Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Wed, 27 Aug 2025 00:33:07 +0900 Subject: [PATCH 076/100] =?UTF-8?q?feat:=20=EC=B1=99=EA=B9=80=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../near/data/mapper/FriendRecordMapper.kt | 12 +++- .../repository/DefaultFriendRepository.kt | 2 - .../friendprofile/FriendProfileScreen.kt | 62 ++++++++++++++---- .../friendprofile/FriendProfileViewModel.kt | 64 +++++++++++++++++-- .../uistate/FriendProfileUIEvent.kt | 9 +++ .../uistate/FriendShipRecordState.kt | 8 +++ 6 files changed, 136 insertions(+), 21 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendProfileUIEvent.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendShipRecordState.kt diff --git a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendRecordMapper.kt b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendRecordMapper.kt index 343e65b9..ce99b806 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendRecordMapper.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendRecordMapper.kt @@ -2,9 +2,19 @@ package com.alarmy.near.data.mapper import com.alarmy.near.model.FriendRecord import com.alarmy.near.network.response.FriendRecordEntity +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter fun FriendRecordEntity.toModel(): FriendRecord = FriendRecord( isChecked = isChecked, - createdAt = createdAt, + createdAt = createdAt.toShortDate(), ) + +private fun String.toShortDate(): String { + val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + val outputFormatter = DateTimeFormatter.ofPattern("yy.MM.dd") + + val dateTime = LocalDateTime.parse(this, inputFormatter) + return dateTime.format(outputFormatter) +} diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt index cbb7bfd9..7651da94 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt @@ -1,6 +1,5 @@ package com.alarmy.near.data.repository -import android.util.Log import com.alarmy.near.data.mapper.toModel import com.alarmy.near.data.mapper.toRequest import com.alarmy.near.model.Friend @@ -28,7 +27,6 @@ class DefaultFriendRepository override fun fetchMonthlyFriends(): Flow> = flow { - Log.d("test", "repository") emit( friendService.fetchMonthlyFriends().map { it.toModel() diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index 7ab26c44..e2736dfd 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -57,10 +57,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R import com.alarmy.near.model.ContactFrequency import com.alarmy.near.model.Friend +import com.alarmy.near.model.FriendRecord import com.alarmy.near.model.Relation import com.alarmy.near.model.ReminderInterval import com.alarmy.near.presentation.feature.friendprofile.component.CallButton import com.alarmy.near.presentation.feature.friendprofile.component.MessageButton +import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendShipRecordState import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendState import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar import com.alarmy.near.presentation.ui.component.button.NearSolidTypeButton @@ -79,8 +81,10 @@ fun FriendProfileRoute( onClickMessageButton: (phoneNumber: String) -> Unit = {}, ) { val friendState = viewModel.friendFlow.collectAsStateWithLifecycle() + val friendShipRecordState = viewModel.friendShipRecordStateFlow.collectAsStateWithLifecycle() FriendProfileScreen( friendState = friendState.value, + friendShipRecordState = friendShipRecordState.value, onClickBackButton = onClickBackButton, onEditFriendInfo = onEditFriendInfo, onClickCallButton = onClickCallButton, @@ -92,6 +96,7 @@ fun FriendProfileRoute( fun FriendProfileScreen( modifier: Modifier = Modifier, friendState: FriendState, + friendShipRecordState: FriendShipRecordState, onClickBackButton: () -> Unit = {}, onEditFriendInfo: (Friend) -> Unit = {}, onClickCallButton: (phoneNumber: String) -> Unit = {}, @@ -319,7 +324,7 @@ fun FriendProfileScreen( if (currentTabPosition.intValue == 0) { ProfileTab(friend = friend) } else { - RecordTab() + RecordTab(friendShipRecordState = friendShipRecordState) } } NearSolidTypeButton( @@ -390,7 +395,10 @@ private fun ProfileTab( } @Composable -private fun RecordTab(modifier: Modifier = Modifier) { +private fun RecordTab( + modifier: Modifier = Modifier, + friendShipRecordState: FriendShipRecordState, +) { Column(modifier = modifier.padding(horizontal = 24.dp)) { Spacer(modifier = Modifier.height(24.dp)) Text( @@ -398,22 +406,38 @@ private fun RecordTab(modifier: Modifier = Modifier) { style = NearTheme.typography.B2_14_BOLD, color = NearTheme.colors.BLACK_1A1A1A, ) - Spacer(modifier = Modifier.height(13.dp)) - - LazyVerticalGrid( - GridCells.Fixed(3), - verticalArrangement = Arrangement.spacedBy(24.dp), - contentPadding = PaddingValues(bottom = 60.dp), - ) { - items(15) { - RecordItem() + if (friendShipRecordState.records.isEmpty()) { + Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(modifier = Modifier.height(60.dp)) + Image(painterResource(R.drawable.img_100_character_empty), contentDescription = null) + Spacer(modifier = Modifier.height(16.dp)) + Text( + "이번달은 챙길 사람이 없네요.", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + } + } else { + Spacer(modifier = Modifier.height(13.dp)) + LazyVerticalGrid( + GridCells.Fixed(3), + verticalArrangement = Arrangement.spacedBy(24.dp), + contentPadding = PaddingValues(bottom = 60.dp), + ) { + items(friendShipRecordState.records.size) { + RecordItem(friendRecord = friendShipRecordState.records[it], index = it) + } } } } } @Composable -private fun RecordItem(modifier: Modifier = Modifier) { +private fun RecordItem( + modifier: Modifier = Modifier, + index: Int, + friendRecord: FriendRecord, +) { Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, @@ -443,7 +467,7 @@ private fun RecordItem(modifier: Modifier = Modifier) { contentDescription = null, ) Text( - "11번째 챙김", + "${index + 1}번째 챙김", style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLUE01_5AA2E9, ) @@ -451,7 +475,7 @@ private fun RecordItem(modifier: Modifier = Modifier) { } Spacer(modifier = Modifier.height(8.dp)) Text( - "25.03.20", + friendRecord.createdAt, style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.GRAY01_888888, ) @@ -610,6 +634,16 @@ fun FriendProfileScreenPreview() { lastContactAt = "", ), ), + friendShipRecordState = + FriendShipRecordState( + records = + List(5) { + FriendRecord( + isChecked = true, + createdAt = "2023-11-1$it", + ) + }, + ), ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt index 07fc49e6..7800bca5 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt @@ -6,7 +6,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.alarmy.near.data.repository.FriendRepository +import com.alarmy.near.model.FriendRecord import com.alarmy.near.presentation.feature.friendprofile.navigation.RouteFriendProfile +import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendProfileUIEvent +import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendShipRecordState import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel @@ -17,6 +20,7 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import javax.inject.Inject @HiltViewModel @@ -27,13 +31,19 @@ class FriendProfileViewModel private val friendRepository: FriendRepository, ) : ViewModel() { private val friendId: String = savedStateHandle.toRoute().friendId - private val _errorEvent = Channel() - val errorEvent = _errorEvent.receiveAsFlow() + private val _uiEvent = Channel() + val uiEvent = _uiEvent.receiveAsFlow() private val _friendFlow: MutableStateFlow = MutableStateFlow(FriendState.Loading) val friendFlow: StateFlow = _friendFlow.asStateFlow() + private val _friendShipRecordStateFlow: MutableStateFlow = + MutableStateFlow(FriendShipRecordState(isLoading = true)) + val friendShipRecordStateFlow: StateFlow = + _friendShipRecordStateFlow.asStateFlow() + init { fetchFriend() + fetchFriendShipRecord() } fun fetchFriend() { @@ -42,12 +52,58 @@ class FriendProfileViewModel .onEach { friend -> _friendFlow.value = FriendState.Success(friend) }.catch { error -> - Log.d("test1", error.message.toString()) _friendFlow.value = FriendState.Error("데이터를 가져오는데 실패했습니다.") - _errorEvent.send(error) // UI에서 단발성 이벤트로도 쓸 수 있음 + _uiEvent.send(FriendProfileUIEvent.NetworkError) // UI에서 단발성 이벤트로도 쓸 수 있음 + }.launchIn(viewModelScope) + } + + fun fetchFriendShipRecord() { + friendRepository + .fetchFriendRecord(friendId) + .onEach { records -> + _friendShipRecordStateFlow.update { + it.copy( + records = records.filter { record -> record.isChecked }, + isLoading = false, + ) + } + }.catch { error -> + _friendShipRecordStateFlow.update { + it.copy( + isLoading = false, + ) + } + _uiEvent.send(FriendProfileUIEvent.NetworkError) // UI에서 단발성 이벤트로도 쓸 수 있음 }.launchIn(viewModelScope) } fun deleteFriend() { + friendRepository + .deleteFriend(friendId) + .onEach { + _uiEvent.send(FriendProfileUIEvent.DeleteFriendSuccess) + // event + }.catch { error -> + _uiEvent.send(FriendProfileUIEvent.NetworkError) // UI에서 단발성 이벤트로도 쓸 수 있음 + }.launchIn(viewModelScope) + } + + fun recordFriendShip() { + friendRepository + .recordContact(friendId) + .onEach { result -> + _uiEvent.send(FriendProfileUIEvent.RecordFriendShipSuccess) + _friendShipRecordStateFlow.update { recordState -> + recordState.copy( + records = + listOf( + FriendRecord(isChecked = true, createdAt = result), + ) + (recordState.records), + ) + } + // event + }.catch { error -> + _uiEvent.send(FriendProfileUIEvent.NetworkError) // UI에서 단발성 이벤트로도 쓸 수 있음 + }.launchIn(viewModelScope) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendProfileUIEvent.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendProfileUIEvent.kt new file mode 100644 index 00000000..c806c606 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendProfileUIEvent.kt @@ -0,0 +1,9 @@ +package com.alarmy.near.presentation.feature.friendprofile.uistate + +sealed interface FriendProfileUIEvent { + data object NetworkError : FriendProfileUIEvent + + data object DeleteFriendSuccess : FriendProfileUIEvent + + data object RecordFriendShipSuccess : FriendProfileUIEvent +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendShipRecordState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendShipRecordState.kt new file mode 100644 index 00000000..e5041ff5 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendShipRecordState.kt @@ -0,0 +1,8 @@ +package com.alarmy.near.presentation.feature.friendprofile.uistate + +import com.alarmy.near.model.FriendRecord + +data class FriendShipRecordState( + val records: List = emptyList(), + val isLoading: Boolean = false, +) From 3367602fcf57dd21faa469fc019ffe7d79d8d3f1 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Wed, 27 Aug 2025 00:33:34 +0900 Subject: [PATCH 077/100] =?UTF-8?q?fix:=20=EC=B1=99=EA=B9=80=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20Entity=20=EC=A7=81=EB=A0=AC=ED=99=94=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/alarmy/near/network/response/FriendRecordEntity.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/FriendRecordEntity.kt b/Near/app/src/main/java/com/alarmy/near/network/response/FriendRecordEntity.kt index ccc9355b..577349c4 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/response/FriendRecordEntity.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/response/FriendRecordEntity.kt @@ -1,5 +1,8 @@ package com.alarmy.near.network.response +import kotlinx.serialization.Serializable + +@Serializable data class FriendRecordEntity( val isChecked: Boolean, val createdAt: String, From 6056356b490a533328953f6bc7c361f7f79a1419 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Wed, 27 Aug 2025 01:41:17 +0900 Subject: [PATCH 078/100] =?UTF-8?q?feat:=20=EC=B1=99=EA=B9=80=20=EB=B0=8F?= =?UTF-8?q?=20=EC=B9=9C=EA=B5=AC=20=EC=82=AD=EC=A0=9C=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/alarmy/near/model/Friend.kt | 18 ++++- .../com/alarmy/near/model/FriendRecord.kt | 2 +- .../friendprofile/FriendProfileScreen.kt | 75 ++++++++++++++++++- .../friendprofile/FriendProfileViewModel.kt | 42 +++++++++-- .../uistate/FriendProfileUIEvent.kt | 4 +- .../drawable/img_100_character_success.xml | 33 ++++++++ 6 files changed, 161 insertions(+), 13 deletions(-) create mode 100644 Near/app/src/main/res/drawable/img_100_character_success.xml diff --git a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt index f4221203..33917147 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt @@ -1,5 +1,9 @@ package com.alarmy.near.model +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale + data class Friend( val friendId: String, val imageUrl: String?, @@ -10,8 +14,18 @@ data class Friend( val anniversaryList: List, val memo: String?, val phone: String?, - val lastContactAt: String?, -) + val lastContactAt: String?, // "2025-07-16" +) { + val isContactedToday: Boolean + get() = lastContactAt?.isToday() ?: false + + private fun String.isToday(): Boolean { + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.KOREA) + val targetDate = LocalDate.parse(this, formatter) + val today = LocalDate.now() + return targetDate == today + } +} data class ContactFrequency( val reminderInterval: ReminderInterval, diff --git a/Near/app/src/main/java/com/alarmy/near/model/FriendRecord.kt b/Near/app/src/main/java/com/alarmy/near/model/FriendRecord.kt index a7754e22..2d6a7459 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/FriendRecord.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/FriendRecord.kt @@ -2,5 +2,5 @@ package com.alarmy.near.model data class FriendRecord( val isChecked: Boolean, - val createdAt: String, + val createdAt: String, // ex) 25.11.12 ) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index e2736dfd..a7eec3b5 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.TabRow import androidx.compose.material3.TabRowDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -43,6 +44,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.res.painterResource @@ -52,6 +54,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R @@ -62,12 +65,15 @@ import com.alarmy.near.model.Relation import com.alarmy.near.model.ReminderInterval import com.alarmy.near.presentation.feature.friendprofile.component.CallButton import com.alarmy.near.presentation.feature.friendprofile.component.MessageButton +import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendProfileUIEvent import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendShipRecordState import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendState import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar import com.alarmy.near.presentation.ui.component.button.NearSolidTypeButton import com.alarmy.near.presentation.ui.extension.onNoRippleClick import com.alarmy.near.presentation.ui.theme.NearTheme +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import java.time.LocalDate import java.time.format.DateTimeFormatter @@ -79,16 +85,43 @@ fun FriendProfileRoute( onEditFriendInfo: (Friend) -> Unit = {}, onClickCallButton: (phoneNumber: String) -> Unit = {}, onClickMessageButton: (phoneNumber: String) -> Unit = {}, + onDeleteFriendSuccess: (friendId: String) -> Unit = {}, ) { val friendState = viewModel.friendFlow.collectAsStateWithLifecycle() val friendShipRecordState = viewModel.friendShipRecordStateFlow.collectAsStateWithLifecycle() + val recordSuccessDialogState = remember { mutableStateOf(false) } + LaunchedEffect(viewModel.uiEvent) { + launch { + viewModel.uiEvent.collect { event -> + when (event) { + is FriendProfileUIEvent.NetworkError -> { + onShowErrorSnackBar(IllegalStateException("네트워크 에러가 발생했습니다.")) + } + + is FriendProfileUIEvent.DeleteFriendSuccess -> { + onDeleteFriendSuccess(event.friendId) + } + + is FriendProfileUIEvent.RecordFriendShipSuccess -> { + recordSuccessDialogState.value = true + } + } + } + } + } FriendProfileScreen( friendState = friendState.value, friendShipRecordState = friendShipRecordState.value, + recordSuccessDialogState = recordSuccessDialogState.value, onClickBackButton = onClickBackButton, onEditFriendInfo = onEditFriendInfo, onClickCallButton = onClickCallButton, onClickMessageButton = onClickMessageButton, + onRecordFriendShip = viewModel::onRecordFriendShip, + onDeleteFriend = viewModel::onDeleteFriend, + onDismissRecordSuccessDialog = { + recordSuccessDialogState.value = false + }, ) } @@ -97,10 +130,14 @@ fun FriendProfileScreen( modifier: Modifier = Modifier, friendState: FriendState, friendShipRecordState: FriendShipRecordState, + recordSuccessDialogState: Boolean = false, onClickBackButton: () -> Unit = {}, onEditFriendInfo: (Friend) -> Unit = {}, onClickCallButton: (phoneNumber: String) -> Unit = {}, onClickMessageButton: (phoneNumber: String) -> Unit = {}, + onRecordFriendShip: (friendId: String) -> Unit = {}, + onDeleteFriend: (friendId: String) -> Unit = {}, + onDismissRecordSuccessDialog: () -> Unit = {}, ) { val density = LocalDensity.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } @@ -122,6 +159,39 @@ fun FriendProfileScreen( .fillMaxSize() .background(NearTheme.colors.WHITE_FFFFFF), ) { + if (recordSuccessDialogState) { + LaunchedEffect(true) { + if (recordSuccessDialogState) { + delay(2000L) + onDismissRecordSuccessDialog() + } + } + Dialog(onDismissRequest = onDismissRecordSuccessDialog) { + Column( + modifier = + Modifier + .width(255.dp) + .height(186.dp) + .background( + color = NearTheme.colors.WHITE_FFFFFF, + shape = RoundedCornerShape(16.dp), + ), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painterResource(R.drawable.img_100_character_success), + contentDescription = "", + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + "더 가까워졌어요!", + style = NearTheme.typography.B1_16_BOLD, + color = Color(0xff222222), + ) + } + } + } NearTopAppbar( title = stringResource(R.string.friend_profile_title), onClickBackButton = onClickBackButton, @@ -157,6 +227,7 @@ fun FriendProfileScreen( ) DropdownMenuItem( onClick = { + onDeleteFriend(friend.friendId) dropdownState.value = false }, text = { @@ -334,8 +405,8 @@ fun FriendProfileScreen( .padding(horizontal = 20.dp) .align(Alignment.BottomCenter), contentPadding = PaddingValues(vertical = 17.dp), - enabled = true, - onClick = {}, + enabled = friend.isContactedToday.not(), + onClick = { onRecordFriendShip(friend.friendId) }, text = stringResource(R.string.friend_profile_record_button_text), ) } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt index 7800bca5..4982e655 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt @@ -1,6 +1,5 @@ package com.alarmy.near.presentation.feature.friendprofile -import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -21,6 +20,9 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale import javax.inject.Inject @HiltViewModel @@ -77,33 +79,59 @@ class FriendProfileViewModel }.launchIn(viewModelScope) } - fun deleteFriend() { + fun onDeleteFriend(friendId: String) { friendRepository .deleteFriend(friendId) .onEach { - _uiEvent.send(FriendProfileUIEvent.DeleteFriendSuccess) + _uiEvent.send(FriendProfileUIEvent.DeleteFriendSuccess(friendId)) // event }.catch { error -> _uiEvent.send(FriendProfileUIEvent.NetworkError) // UI에서 단발성 이벤트로도 쓸 수 있음 }.launchIn(viewModelScope) } - fun recordFriendShip() { + fun onRecordFriendShip(friendId: String) { friendRepository - .recordContact(friendId) - .onEach { result -> + .recordContact(friendId) // 내 현재 시간 가져와서 + .onEach { _ -> _uiEvent.send(FriendProfileUIEvent.RecordFriendShipSuccess) _friendShipRecordStateFlow.update { recordState -> recordState.copy( records = listOf( - FriendRecord(isChecked = true, createdAt = result), + FriendRecord( + isChecked = true, + createdAt = getTodayShortFormat(), + ), ) + (recordState.records), ) } + if (friendFlow.value is FriendState.Success) { + _friendFlow.update { + (it as FriendState.Success).copy( + friend = + it.friend.copy( + lastContactAt = getTodayDashFormat(), + ), + ) + } + } + // event }.catch { error -> _uiEvent.send(FriendProfileUIEvent.NetworkError) // UI에서 단발성 이벤트로도 쓸 수 있음 }.launchIn(viewModelScope) } + + private fun getTodayShortFormat(): String { + val today = LocalDate.now() + val formatter = DateTimeFormatter.ofPattern("yy.MM.dd", Locale.KOREA) + return today.format(formatter) + } + + private fun getTodayDashFormat(): String { + val today = LocalDate.now() + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.KOREA) + return today.format(formatter) + } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendProfileUIEvent.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendProfileUIEvent.kt index c806c606..87133fef 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendProfileUIEvent.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendProfileUIEvent.kt @@ -3,7 +3,9 @@ package com.alarmy.near.presentation.feature.friendprofile.uistate sealed interface FriendProfileUIEvent { data object NetworkError : FriendProfileUIEvent - data object DeleteFriendSuccess : FriendProfileUIEvent + data class DeleteFriendSuccess( + val friendId: String, + ) : FriendProfileUIEvent data object RecordFriendShipSuccess : FriendProfileUIEvent } diff --git a/Near/app/src/main/res/drawable/img_100_character_success.xml b/Near/app/src/main/res/drawable/img_100_character_success.xml new file mode 100644 index 00000000..db210bef --- /dev/null +++ b/Near/app/src/main/res/drawable/img_100_character_success.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + From d81ed8c44814945657808566539a000e5c7dde5f Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Wed, 27 Aug 2025 19:23:05 +0900 Subject: [PATCH 079/100] =?UTF-8?q?feat:=20Friend=20Navigation=20=EC=9D=B8?= =?UTF-8?q?=EC=9E=90=20=EC=A0=84=EB=8B=AC=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?Serializable=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Near/app/src/main/java/com/alarmy/near/model/Friend.kt | 4 ++++ .../navigation/FriendProfileNavigation.kt | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt index 33917147..1acfe62a 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt @@ -1,9 +1,11 @@ package com.alarmy.near.model +import kotlinx.serialization.Serializable import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.Locale +@Serializable data class Friend( val friendId: String, val imageUrl: String?, @@ -27,11 +29,13 @@ data class Friend( } } +@Serializable data class ContactFrequency( val reminderInterval: ReminderInterval, val dayOfWeek: String, ) +@Serializable data class Anniversary( val id: Int, val title: String, diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt index d7521c67..7fa1db3e 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt @@ -4,11 +4,14 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable +import com.alarmy.near.model.Friend import com.alarmy.near.presentation.feature.friendprofileedittor.FriendProfileEditorRoute import kotlinx.serialization.Serializable @Serializable -object RouteFriendProfileEditor +data class RouteFriendProfileEditor( + val friend: Friend, +) fun NavController.navigateToFriendProfileEditor(navOptions: NavOptions) { navigate(RouteFriendProfileEditor, navOptions) From b802953de0c8f7341b7ef6302c12b864c178c0ae Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sat, 30 Aug 2025 17:03:46 +0900 Subject: [PATCH 080/100] =?UTF-8?q?feat:=20parcelize=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Near/app/build.gradle.kts | 1 + Near/gradle/libs.versions.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/Near/app/build.gradle.kts b/Near/app/build.gradle.kts index 6ab76de2..1e85f397 100644 --- a/Near/app/build.gradle.kts +++ b/Near/app/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.hilt.application) alias(libs.plugins.kotlin.kapt) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotlin.parcelize) } android { diff --git a/Near/gradle/libs.versions.toml b/Near/gradle/libs.versions.toml index 21b73514..d3aa3673 100644 --- a/Near/gradle/libs.versions.toml +++ b/Near/gradle/libs.versions.toml @@ -72,4 +72,5 @@ hilt-application = { id = "com.google.dagger.hilt.android", version.ref = "hiltV kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintVersion" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-parcelize = {id = "kotlin-parcelize"} From 0127f097008f2c101b15371e1091abc35cc8910c Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sat, 30 Aug 2025 17:05:51 +0900 Subject: [PATCH 081/100] =?UTF-8?q?feat:=20Route=20=EC=9D=B8=EC=9E=90=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=EC=9D=84=20=EC=9C=84=ED=95=9C=20serializable?= =?UTF-8?q?,=20pacelable=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src/main/java/com/alarmy/near/model/Friend.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt index 1acfe62a..14c63a49 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt @@ -1,10 +1,13 @@ package com.alarmy.near.model +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.Locale +@Parcelize @Serializable data class Friend( val friendId: String, @@ -17,7 +20,7 @@ data class Friend( val memo: String?, val phone: String?, val lastContactAt: String?, // "2025-07-16" -) { +) : Parcelable { val isContactedToday: Boolean get() = lastContactAt?.isToday() ?: false @@ -30,14 +33,16 @@ data class Friend( } @Serializable +@Parcelize data class ContactFrequency( val reminderInterval: ReminderInterval, val dayOfWeek: String, -) +) : Parcelable @Serializable +@Parcelize data class Anniversary( val id: Int, val title: String, val date: String, -) +) : Parcelable From 817ec4089adef963b07f7a784bdc1e97c5778f8c Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sat, 30 Aug 2025 17:09:00 +0900 Subject: [PATCH 082/100] =?UTF-8?q?feat:=20friend=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EC=A0=84=EB=8B=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FriendProfileEditorScreen.kt | 54 +++++- .../FriendProfileEditorViewModel.kt | 154 +++++++++++++++++- .../navigation/FriendProfileNavigation.kt | 64 +++++++- .../uistate/FriendProfileEditorUIState.kt | 42 +++++ .../presentation/feature/main/NearNavHost.kt | 3 + 5 files changed, 299 insertions(+), 18 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index fab2cd0f..91e7f658 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -36,9 +36,15 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R +import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.Relation +import com.alarmy.near.model.ReminderInterval import com.alarmy.near.presentation.feature.friendprofileedittor.component.NearDatePicker import com.alarmy.near.presentation.feature.friendprofileedittor.component.ReminderIntervalBottomSheet +import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIState import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar import com.alarmy.near.presentation.ui.component.radiobutton.NearSmallRadioButton import com.alarmy.near.presentation.ui.component.textfield.NearLimitedTextField @@ -48,11 +54,16 @@ import com.alarmy.near.presentation.ui.theme.NearTheme @Composable fun FriendProfileEditorRoute( + viewModel: FriendProfileEditorViewModel = hiltViewModel(), onShowErrorSnackBar: (throwable: Throwable?) -> Unit, onClickBackButton: () -> Unit = {}, ) { + val friendProfileEditorUIState = viewModel.uiState.collectAsStateWithLifecycle() FriendProfileEditorScreen( onClickBackButton = onClickBackButton, + onNameChanged = viewModel::onNameChanged, + friendProfileEditorUIState = friendProfileEditorUIState.value, + onRelationChanged = viewModel::onRelationChanged, ) } @@ -60,7 +71,10 @@ fun FriendProfileEditorRoute( @Composable fun FriendProfileEditorScreen( modifier: Modifier = Modifier, + friendProfileEditorUIState: FriendProfileEditorUIState, onClickBackButton: () -> Unit = {}, + onNameChanged: (String) -> Unit = {}, + onRelationChanged: (Relation) -> Unit = {}, ) { val isErrorMessageVisible = remember { mutableStateOf(false) } val density = LocalDensity.current @@ -123,8 +137,9 @@ fun FriendProfileEditorScreen( Spacer(modifier = Modifier.width(55.dp)) NearTextField( modifier = Modifier.weight(1f), - value = "가나다", + value = friendProfileEditorUIState.name.value, onValueChange = { + onNameChanged(it) }, ) } @@ -156,9 +171,15 @@ fun FriendProfileEditorScreen( .padding(end = 35.dp), horizontalArrangement = Arrangement.SpaceBetween, ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = + Modifier.onNoRippleClick(onClick = { + onRelationChanged(Relation.FRIEND) + }), + verticalAlignment = Alignment.CenterVertically, + ) { NearSmallRadioButton( - selected = false, + selected = friendProfileEditorUIState.relation == Relation.FRIEND, onClick = {}, ) Spacer(modifier = Modifier.width(8.dp)) @@ -168,9 +189,15 @@ fun FriendProfileEditorScreen( color = NearTheme.colors.BLACK_1A1A1A, ) } - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = + Modifier.onNoRippleClick(onClick = { + onRelationChanged(Relation.FAMILY) + }), + verticalAlignment = Alignment.CenterVertically, + ) { NearSmallRadioButton( - selected = false, + selected = friendProfileEditorUIState.relation == Relation.FAMILY, onClick = {}, ) Spacer(modifier = Modifier.width(8.dp)) @@ -182,8 +209,8 @@ fun FriendProfileEditorScreen( } Row(verticalAlignment = Alignment.CenterVertically) { NearSmallRadioButton( - selected = false, - onClick = {}, + selected = friendProfileEditorUIState.relation == Relation.ACQUAINTANCE, + onClick = { onRelationChanged(Relation.ACQUAINTANCE) }, ) Spacer(modifier = Modifier.width(8.dp)) Text( @@ -234,7 +261,7 @@ fun FriendProfileEditorScreen( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - "2주 (수요일 마다)", + text = stringResource(friendProfileEditorUIState.contactFrequency.reminderInterval.labelRes) + "()", style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLACK_1A1A1A, ) @@ -469,6 +496,15 @@ fun FriendProfileEditorScreen( @Composable fun FriendProfileEditorScreenPreview() { NearTheme { - FriendProfileEditorScreen() + FriendProfileEditorScreen( + friendProfileEditorUIState = + FriendProfileEditorUIState( + contactFrequency = + ContactFrequency( + reminderInterval = ReminderInterval.EVERY_DAY, + dayOfWeek = "2025-01-01", + ), + ), + ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt index d3cbb6ce..0e50341f 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt @@ -1,21 +1,165 @@ package com.alarmy.near.presentation.feature.friendprofileedittor +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.navigation.toRoute +import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.Friend import com.alarmy.near.model.Relation +import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.RouteFriendProfileEditor +import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.AnniversaryUIState +import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIState +import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.toUiModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import java.time.LocalDate import javax.inject.Inject @HiltViewModel class FriendProfileEditorViewModel @Inject - constructor() : ViewModel() { - private val _relation: MutableStateFlow = MutableStateFlow(null) - val relation = _relation.asStateFlow() + constructor( + savedStateHandle: SavedStateHandle, + ) : ViewModel() { + private val routeFriendProfileEditor: RouteFriendProfileEditor = + savedStateHandle.toRoute(RouteFriendProfileEditor.routeTypeMap) + private val friend: Friend = routeFriendProfileEditor.friend + private val _uiState: MutableStateFlow = + MutableStateFlow(friend.toUiModel()) + val uiState = _uiState.asStateFlow() - fun setRelation(relation: Relation) { - _relation.update { relation } + fun onNameChanged(value: String) { + _uiState.update { + it.copy( + name = + it.name.copy( + value = value, + isDirty = true, + error = null, + ), + ) + } } + + fun onRelationChanged(value: Relation) { + _uiState.update { it.copy(relation = value) } + } + + fun onContactFrequencyChanged(value: ContactFrequency) { + _uiState.update { it.copy(contactFrequency = value) } + } + + fun onBirthdayChanged(value: LocalDate?) { + _uiState.update { + it.copy( + birthday = + it.birthday.copy( + value = "", + isDirty = true, + error = null, + ), + ) + } + } + + fun onAnniversaryTitleChanged( + index: Int, + value: String, + ) { + _uiState.update { + it.copy( + anniversaries = + it.anniversaries.toMutableList().apply { + this[index] = + this[index].copy( + title = + this[index].title.copy( + value = value, + isDirty = true, + error = null, + ), + ) + }, + ) + } + } + + fun onAnniversaryDateChanged( + index: Int, + value: LocalDate?, + ) { + _uiState.update { + it.copy( + anniversaries = + it.anniversaries.toMutableList().apply { + this[index] = + this[index].copy( + date = + this[index].date.copy( + value = "", + isDirty = true, + error = null, + ), + ) + }, + ) + } + } + + fun onAddAnniversary() { + _uiState.update { it.copy(anniversaries = it.anniversaries + AnniversaryUIState()) } + } + + fun onRemoveAnniversary(index: Int) { + _uiState.update { + it.copy(anniversaries = it.anniversaries.toMutableList().apply { removeAt(index) }) + } + } + + fun onMemoChanged(value: String) { + _uiState.update { + it.copy( + memo = + it.memo.copy( + value = value, + isDirty = true, + error = null, + ), + ) + } + } + +// fun onSubmit() { +// val model = _uiState.value +// val validated = +// model.copy( +// name = model.name.copy(error = if (model.name.value.isBlank()) "이름을 입력해주세요." else null), +// anniversaries = +// model.anniversaries.map { anniversary -> +// anniversary.copy( +// title = anniversary.title.copy(error = if (anniversary.title.value.isBlank()) "기념일 이름을 입력해주세요." else null), +// date = anniversary.date.copy(error = if (anniversary.date.value == null) "날짜를 선택해주세요." else null), +// ) +// }, +// ) +// +// // _uiState.update { it.copy( = validated) } +// +// // Validation 성공 시 Repository 저장 +// if (validated.name.error == null && +// validated.anniversaries.all { it.title.error == null && it.date.error == null } +// ) { +// _uiState.update { it.copy(isSubmitting = true) } +// viewModelScope.launch { +// // repository.save(validated.toDomain()) +// _uiState.update { it.copy(isSubmitting = false, isSuccess = true) } +// } +// } +// } +// +// private fun update(transform: FriendUiModel.() -> FriendUiModel) { +// _uiState.update { state -> state.copy(model = state.model.transform()) } +// } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt index 7fa1db3e..737f9814 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt @@ -1,30 +1,86 @@ package com.alarmy.near.presentation.feature.friendprofileedittor.navigation +import android.os.Build +import android.os.Parcelable import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions +import androidx.navigation.NavType import androidx.navigation.compose.composable +import androidx.savedstate.SavedState import com.alarmy.near.model.Friend import com.alarmy.near.presentation.feature.friendprofileedittor.FriendProfileEditorRoute +import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.RouteFriendProfileEditor.Companion.routeTypeMap +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.reflect.KType +import kotlin.reflect.typeOf @Serializable +@Parcelize data class RouteFriendProfileEditor( val friend: Friend, -) +) : Parcelable { + companion object { + val routeTypeMap = + mapOf>( + typeOf() to FriendType, + ) + } +} -fun NavController.navigateToFriendProfileEditor(navOptions: NavOptions) { - navigate(RouteFriendProfileEditor, navOptions) +fun NavController.navigateToFriendProfileEditor( + friend: Friend, + navOptions: NavOptions? = null, +) { + navigate( + RouteFriendProfileEditor( + friend = friend, + ), + navOptions, + ) } fun NavGraphBuilder.friendProfileEditorNavGraph( onShowErrorSnackBar: (throwable: Throwable?) -> Unit, onClickBackButton: () -> Unit = {}, ) { - composable { backStackEntry -> + composable( + typeMap = + routeTypeMap, + ) { backStackEntry -> FriendProfileEditorRoute( onShowErrorSnackBar = onShowErrorSnackBar, onClickBackButton = onClickBackButton, ) } } + +internal val FriendType = + object : NavType( + isNullableAllowed = false, + ) { + override fun put( + bundle: SavedState, + key: String, + value: Friend, + ) { + bundle.putParcelable(key, value) + } + + override fun get( + bundle: SavedState, + key: String, + ): Friend? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + bundle.getParcelable(key, Friend::class.java) + } else { + @Suppress("DEPRECATION") + bundle.getParcelable(key) + } + + override fun parseValue(value: String): Friend = Json.decodeFromString(value) + + override fun serializeAsValue(value: Friend): String = Json.encodeToString(value) + } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt new file mode 100644 index 00000000..f4472d7a --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt @@ -0,0 +1,42 @@ +package com.alarmy.near.presentation.feature.friendprofileedittor.uistate + +import com.alarmy.near.model.Anniversary +import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.Friend +import com.alarmy.near.model.Relation + +data class FriendProfileEditorUIState( + val name: InputField = InputField(""), + val relation: Relation = Relation.FRIEND, + val contactFrequency: ContactFrequency, + val birthday: InputField = InputField(null), + val anniversaries: List = emptyList(), + val memo: InputField = InputField(null), +) + +data class AnniversaryUIState( + val title: InputField = InputField(""), + val date: InputField = InputField(null), +) + +data class InputField( + val value: T, + val error: String? = null, // null이면 유효한 상태 + val isDirty: Boolean = false, // 유저가 입력을 시도했는지 +) + +fun Friend.toUiModel(): FriendProfileEditorUIState = + FriendProfileEditorUIState( + name = InputField(name), + relation = relation, + contactFrequency = contactFrequency, + birthday = InputField(birthday), + anniversaries = anniversaryList.map { it.toUiModel() }, + memo = InputField(memo) + ) + +fun Anniversary.toUiModel(): AnniversaryUIState = + AnniversaryUIState( + title = InputField(title), + date = InputField(date) + ) 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 ce363dd4..331498de 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,7 @@ import androidx.navigation.compose.NavHost import com.alarmy.near.presentation.feature.friendprofile.navigation.friendProfileNavGraph import com.alarmy.near.presentation.feature.friendprofile.navigation.navigateToFriendProfile import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.friendProfileEditorNavGraph +import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.navigateToFriendProfileEditor import com.alarmy.near.presentation.feature.home.navigation.RouteHome import com.alarmy.near.presentation.feature.home.navigation.homeNavGraph import com.alarmy.near.presentation.feature.home.navigation.navigateToHome @@ -45,6 +46,8 @@ internal fun NearNavHost( data = "sms:$phoneNumber".toUri() } context.startActivity(intent) + }, onEditFriendInfo = { + navController.navigateToFriendProfileEditor(friend = it) }) friendProfileEditorNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = { navController.popBackStack() From 010d3958c67377e785b4f61e98596dbc8673ea75 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sat, 30 Aug 2025 20:27:29 +0900 Subject: [PATCH 083/100] =?UTF-8?q?feat:=20=EC=A0=95=EB=B3=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FriendProfileEditorScreen.kt | 252 ++++++++++-------- .../FriendProfileEditorViewModel.kt | 56 ++-- .../component/NearDatePicker.kt | 26 +- .../uistate/FriendProfileEditorUIState.kt | 21 +- 4 files changed, 220 insertions(+), 135 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index 91e7f658..ac5bb3fc 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -64,6 +64,13 @@ fun FriendProfileEditorRoute( onNameChanged = viewModel::onNameChanged, friendProfileEditorUIState = friendProfileEditorUIState.value, onRelationChanged = viewModel::onRelationChanged, + onReminderIntervalChanged = viewModel::onRemindIntervalChanged, + onBirthdayChanged = viewModel::onBirthdayChanged, + onAnniversaryNameChange = viewModel::onAnniversaryTitleChanged, + onAnniversaryDateSelected = viewModel::onAnniversaryDateChanged, + onRemoveAnniversary = viewModel::onRemoveAnniversary, + onAddAnniversary = viewModel::onAddAnniversary, + onMemoChanged = viewModel::onMemoChanged, ) } @@ -75,14 +82,23 @@ fun FriendProfileEditorScreen( onClickBackButton: () -> Unit = {}, onNameChanged: (String) -> Unit = {}, onRelationChanged: (Relation) -> Unit = {}, + onReminderIntervalChanged: (ReminderInterval) -> Unit = {}, + onBirthdayChanged: (Long) -> Unit = {}, + onAnniversaryNameChange: (index: Int, name: String) -> Unit = { _, _ -> }, + onAnniversaryDateSelected: (index: Int, dataTimeMillis: Long) -> Unit = { _, _ -> }, + onRemoveAnniversary: (index: Int) -> Unit = { _ -> }, + onAddAnniversary: () -> Unit = {}, + onMemoChanged: (String) -> Unit = {}, ) { - val isErrorMessageVisible = remember { mutableStateOf(false) } val density = LocalDensity.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } val showBottomSheet = remember { mutableStateOf(false) } if (showBottomSheet.value) { ReminderIntervalBottomSheet(onDismissRequest = { showBottomSheet.value = false + }, onSelectReminderInterval = { + onReminderIntervalChanged(it) + showBottomSheet.value = false }) } Column( @@ -143,7 +159,7 @@ fun FriendProfileEditorScreen( }, ) } - if (isErrorMessageVisible.value) { + if (friendProfileEditorUIState.name.error) { Spacer(modifier = Modifier.height(8.dp)) Text( "이름을 입력해주세요.", @@ -261,7 +277,9 @@ fun FriendProfileEditorScreen( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = stringResource(friendProfileEditorUIState.contactFrequency.reminderInterval.labelRes) + "()", + text = + stringResource(friendProfileEditorUIState.contactFrequency.reminderInterval.labelRes) + + "(${friendProfileEditorUIState.contactFrequency.dayOfWeek} 마다)", style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLACK_1A1A1A, ) @@ -286,7 +304,11 @@ fun FriendProfileEditorScreen( NearDatePicker( datePickerState = datePickerState, onDismiss = { birthdayDatePickerState.value = false }, - onDateSelected = {}, + onDateSelected = { + it?.let { + onBirthdayChanged(it) + } + }, ) } Text( @@ -315,14 +337,15 @@ fun FriendProfileEditorScreen( end = 12.dp, top = 14.dp, bottom = 14.dp, - ).onNoRippleClick({ + ) + .onNoRippleClick({ birthdayDatePickerState.value = true }), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - "2020.06.24", + friendProfileEditorUIState.birthday.value ?: "날짜 선택", style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLACK_1A1A1A, ) @@ -344,7 +367,10 @@ fun FriendProfileEditorScreen( color = NearTheme.colors.GRAY01_888888, ) Text( - modifier = Modifier.onNoRippleClick(onClick = {}), + modifier = + Modifier.onNoRippleClick(onClick = { + onAddAnniversary() + }), text = "추가하기", style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLUE01_5AA2E9, @@ -352,110 +378,127 @@ fun FriendProfileEditorScreen( } Spacer(modifier = Modifier.height(16.dp)) } - LazyColumn( - modifier = Modifier.background(color = NearTheme.colors.BG02_F4F9FD), - contentPadding = - PaddingValues( - top = 20.dp, - bottom = 32.dp, - start = 24.dp, - end = 20.dp, - ), - verticalArrangement = Arrangement.spacedBy(32.dp), - ) { - item { - Column { - val anniversaryDatePickerState = remember { mutableStateOf(false) } - val datePickerState = - rememberDatePickerState() - if (anniversaryDatePickerState.value) { - NearDatePicker( - datePickerState = datePickerState, - onDismiss = { anniversaryDatePickerState.value = false }, - onDateSelected = {}, - ) - } - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "기념일 이름", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(23.dp)) - NearTextField( - modifier = Modifier.weight(1f), - value = "가나다", - onValueChange = { - }, - ) - } - if (isErrorMessageVisible.value) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - "이름을 입력해주세요.", - style = NearTheme.typography.FC_12_MEDIUM, - color = NearTheme.colors.NEGATIVE_F04E4E, - ) - } - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = - Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "날짜", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(62.dp)) - Surface( + if (friendProfileEditorUIState.anniversaries.isNotEmpty()) { + LazyColumn( + modifier = Modifier.background(color = NearTheme.colors.BG02_F4F9FD), + contentPadding = + PaddingValues( + top = 20.dp, + bottom = 32.dp, + start = 24.dp, + end = 20.dp, + ), + verticalArrangement = Arrangement.spacedBy(32.dp), + ) { + items( + count = friendProfileEditorUIState.anniversaries.size, + ) { index -> + Column { + val anniversaryDatePickerState = remember { mutableStateOf(false) } + val datePickerState = + rememberDatePickerState() + if (anniversaryDatePickerState.value) { + NearDatePicker( + datePickerState = datePickerState, + onDismiss = { anniversaryDatePickerState.value = false }, + onDateSelected = { + it?.let { + onAnniversaryDateSelected(index, it) + } + }, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "기념일 이름", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(23.dp)) + NearTextField( + modifier = Modifier.weight(1f), + value = friendProfileEditorUIState.anniversaries[index].title.value, + onValueChange = { + onAnniversaryNameChange(index, it) + }, + ) + } + if (friendProfileEditorUIState.anniversaries[index].title.error) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + "이름을 입력해주세요.", + style = NearTheme.typography.FC_12_MEDIUM, + color = NearTheme.colors.NEGATIVE_F04E4E, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Row( modifier = - modifier - .weight(1f) - .onNoRippleClick(onClick = { - anniversaryDatePickerState.value = true - }), - shape = RoundedCornerShape(12.dp), - border = - BorderStroke( - width = 1.dp, - color = NearTheme.colors.GRAY03_EBEBEB, - ), - color = NearTheme.colors.WHITE_FFFFFF, + Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, ) { - Row( + Text( + "날짜", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(62.dp)) + Surface( modifier = - Modifier.padding( - start = 16.dp, - end = 12.dp, - top = 14.dp, - bottom = 14.dp, + modifier + .weight(1f) + .onNoRippleClick(onClick = { + anniversaryDatePickerState.value = true + }), + shape = RoundedCornerShape(12.dp), + border = + BorderStroke( + width = 1.dp, + color = NearTheme.colors.GRAY03_EBEBEB, ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + color = NearTheme.colors.WHITE_FFFFFF, ) { - Text( - "2020.06.24", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - Image( - painter = painterResource(id = R.drawable.ic_24_down), - contentDescription = null, - ) + Row( + modifier = + Modifier.padding( + start = 16.dp, + end = 12.dp, + top = 14.dp, + bottom = 14.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + friendProfileEditorUIState.anniversaries[index].date.value + ?: "날짜 선택", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + Image( + painter = painterResource(id = R.drawable.ic_24_down), + contentDescription = null, + ) + } } } + Spacer(modifier = Modifier.height(32.dp)) + Text( + modifier = + Modifier + .fillMaxWidth() + .onNoRippleClick( + onClick = { + onRemoveAnniversary(index) + }, + ), + textAlign = TextAlign.End, + text = "삭제하기", + textDecoration = TextDecoration.Underline, + color = NearTheme.colors.GRAY01_888888, + ) } - Spacer(modifier = Modifier.height(32.dp)) - Text( - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.End, - text = "삭제하기", - textDecoration = TextDecoration.Underline, - color = NearTheme.colors.GRAY01_888888, - ) } } } @@ -478,8 +521,9 @@ fun FriendProfileEditorScreen( Modifier .weight(1f) .height(180.dp), - value = "", + value = friendProfileEditorUIState.memo.value ?: "", onValueChange = { + onMemoChanged(it) }, placeHolderText = "꼭 기억해야 할 내용을 기록해보세요.\n" + diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt index 0e50341f..2a2b515e 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt @@ -3,9 +3,9 @@ package com.alarmy.near.presentation.feature.friendprofileedittor import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.navigation.toRoute -import com.alarmy.near.model.ContactFrequency import com.alarmy.near.model.Friend import com.alarmy.near.model.Relation +import com.alarmy.near.model.ReminderInterval import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.RouteFriendProfileEditor import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.AnniversaryUIState import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIState @@ -14,7 +14,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import java.time.LocalDate +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import javax.inject.Inject @HiltViewModel @@ -25,19 +27,25 @@ class FriendProfileEditorViewModel ) : ViewModel() { private val routeFriendProfileEditor: RouteFriendProfileEditor = savedStateHandle.toRoute(RouteFriendProfileEditor.routeTypeMap) - private val friend: Friend = routeFriendProfileEditor.friend + private val friend: Friend = + routeFriendProfileEditor.friend.apply { + copy() + } private val _uiState: MutableStateFlow = MutableStateFlow(friend.toUiModel()) val uiState = _uiState.asStateFlow() fun onNameChanged(value: String) { + if (value.length > MAX_NAME_LENGTH) { + return + } _uiState.update { it.copy( name = it.name.copy( value = value, isDirty = true, - error = null, + error = value.isEmpty(), ), ) } @@ -47,18 +55,24 @@ class FriendProfileEditorViewModel _uiState.update { it.copy(relation = value) } } - fun onContactFrequencyChanged(value: ContactFrequency) { - _uiState.update { it.copy(contactFrequency = value) } + fun onRemindIntervalChanged(value: ReminderInterval) { + _uiState.update { + it.copy( + contactFrequency = + it.contactFrequency.copy( + reminderInterval = value, + ), + ) + } } - fun onBirthdayChanged(value: LocalDate?) { + fun onBirthdayChanged(value: Long) { _uiState.update { it.copy( birthday = it.birthday.copy( - value = "", + value = convertMillisToDate(value), isDirty = true, - error = null, ), ) } @@ -78,7 +92,7 @@ class FriendProfileEditorViewModel this[index].title.copy( value = value, isDirty = true, - error = null, + error = value.isEmpty(), ), ) }, @@ -88,7 +102,7 @@ class FriendProfileEditorViewModel fun onAnniversaryDateChanged( index: Int, - value: LocalDate?, + value: Long, ) { _uiState.update { it.copy( @@ -98,9 +112,8 @@ class FriendProfileEditorViewModel this[index].copy( date = this[index].date.copy( - value = "", + value = convertMillisToDate(value), isDirty = true, - error = null, ), ) }, @@ -118,19 +131,28 @@ class FriendProfileEditorViewModel } } - fun onMemoChanged(value: String) { + fun onMemoChanged(value: String?) { + value?.length?.let { + if (it > MAX_MEMO_LENGTH) { + return + } + } _uiState.update { it.copy( memo = it.memo.copy( value = value, isDirty = true, - error = null, ), ) } } + private fun convertMillisToDate(millis: Long): String { + val formatter = SimpleDateFormat("yyyy.MM.dd", Locale.getDefault()) + return formatter.format(Date(millis)) + } + // fun onSubmit() { // val model = _uiState.value // val validated = @@ -162,4 +184,8 @@ class FriendProfileEditorViewModel // private fun update(transform: FriendUiModel.() -> FriendUiModel) { // _uiState.update { state -> state.copy(model = state.model.transform()) } // } + companion object { + private const val MAX_NAME_LENGTH = 20 + private const val MAX_MEMO_LENGTH = 200 +} } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/NearDatePicker.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/NearDatePicker.kt index a3bfca5b..2f23a951 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/NearDatePicker.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/NearDatePicker.kt @@ -27,9 +27,10 @@ fun NearDatePicker( onDismiss: () -> Unit, ) { DatePickerDialog( - colors = DatePickerDefaults.colors().copy( - containerColor = NearTheme.colors.WHITE_FFFFFF, - ), + colors = + DatePickerDefaults.colors().copy( + containerColor = NearTheme.colors.WHITE_FFFFFF, + ), onDismissRequest = onDismiss, confirmButton = { TextButton(onClick = { @@ -45,21 +46,22 @@ fun NearDatePicker( } }, ) { - DatePicker(state = datePickerState, title = null, headline = null, showModeToggle = false, + DatePicker( + state = datePickerState, + title = null, + headline = null, + showModeToggle = false, dateFormatter = remember { DatePickerDefaults.dateFormatter() }, - colors = DatePickerDefaults.colors().copy( - containerColor = NearTheme.colors.WHITE_FFFFFF, - )) + colors = + DatePickerDefaults.colors().copy( + containerColor = NearTheme.colors.WHITE_FFFFFF, + ), + ) } } -private fun convertMillisToDate(millis: Long): String { - val formatter = SimpleDateFormat("MM/dd/yyyy", Locale.getDefault()) - return formatter.format(Date(millis)) -} - @OptIn(ExperimentalMaterial3Api::class) @Preview(showBackground = true) @Composable diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt index f4472d7a..282a79d7 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt @@ -21,7 +21,7 @@ data class AnniversaryUIState( data class InputField( val value: T, - val error: String? = null, // null이면 유효한 상태 + val error: Boolean = false, // null이면 유효한 상태 val isDirty: Boolean = false, // 유저가 입력을 시도했는지 ) @@ -29,14 +29,27 @@ fun Friend.toUiModel(): FriendProfileEditorUIState = FriendProfileEditorUIState( name = InputField(name), relation = relation, - contactFrequency = contactFrequency, + contactFrequency = + contactFrequency.copy( + dayOfWeek = + when (contactFrequency.dayOfWeek) { + "MONDAY" -> "월요일" + "TUESDAY" -> "화요일" + "WEDNESDAY" -> "수요일" + "THURSDAY" -> "목요일" + "FRIDAY" -> "금요일" + "SATURDAY" -> "토요일" + "SUNDAY" -> "일요일" + else -> IllegalStateException("없는 타입 입니다.") + } as String, + ), birthday = InputField(birthday), anniversaries = anniversaryList.map { it.toUiModel() }, - memo = InputField(memo) + memo = InputField(memo), ) fun Anniversary.toUiModel(): AnniversaryUIState = AnniversaryUIState( title = InputField(title), - date = InputField(date) + date = InputField(date), ) From 6eb87ddf9cba400c2052a207ca5f66821f0309c2 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 31 Aug 2025 16:08:36 +0900 Subject: [PATCH 084/100] =?UTF-8?q?feat:=20=EC=A0=95=EB=B3=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/alarmy/near/model/Friend.kt | 4 +- .../near/network/request/FriendRequest.kt | 4 +- .../FriendProfileEditorScreen.kt | 9 +++- .../FriendProfileEditorViewModel.kt | 42 +++++++++++++++---- .../uistate/FriendProfileEditorUIState.kt | 38 +++++++++++++++++ 5 files changed, 84 insertions(+), 13 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt index 14c63a49..8cba165a 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt @@ -42,7 +42,7 @@ data class ContactFrequency( @Serializable @Parcelize data class Anniversary( - val id: Int, + val id: Int? = null, val title: String, - val date: String, + val date: String? = null, ) : Parcelable diff --git a/Near/app/src/main/java/com/alarmy/near/network/request/FriendRequest.kt b/Near/app/src/main/java/com/alarmy/near/network/request/FriendRequest.kt index 1aa1099e..fc9878e2 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/request/FriendRequest.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/request/FriendRequest.kt @@ -21,7 +21,7 @@ data class ContactFrequencyRequest( @Serializable data class AnniversaryRequest( - val id: Int, + val id: Int? = null, val title: String, - val date: String, + val date: String? = null, ) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index ac5bb3fc..8aaa835c 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -71,6 +71,7 @@ fun FriendProfileEditorRoute( onRemoveAnniversary = viewModel::onRemoveAnniversary, onAddAnniversary = viewModel::onAddAnniversary, onMemoChanged = viewModel::onMemoChanged, + onSubmit = viewModel::onSubmit, ) } @@ -89,6 +90,7 @@ fun FriendProfileEditorScreen( onRemoveAnniversary: (index: Int) -> Unit = { _ -> }, onAddAnniversary: () -> Unit = {}, onMemoChanged: (String) -> Unit = {}, + onSubmit: () -> Unit = {}, ) { val density = LocalDensity.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } @@ -114,6 +116,10 @@ fun FriendProfileEditorScreen( onClickBackButton = onClickBackButton, menuButton = { Text( + modifier = + Modifier.onNoRippleClick(onClick = { + onSubmit() + }), text = "완료", style = NearTheme.typography.B1_16_BOLD, color = NearTheme.colors.BLACK_1A1A1A, @@ -337,8 +343,7 @@ fun FriendProfileEditorScreen( end = 12.dp, top = 14.dp, bottom = 14.dp, - ) - .onNoRippleClick({ + ).onNoRippleClick({ birthdayDatePickerState.value = true }), verticalAlignment = Alignment.CenterVertically, diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt index 2a2b515e..37c88734 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt @@ -2,18 +2,22 @@ package com.alarmy.near.presentation.feature.friendprofileedittor import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute +import com.alarmy.near.data.repository.FriendRepository import com.alarmy.near.model.Friend import com.alarmy.near.model.Relation import com.alarmy.near.model.ReminderInterval import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.RouteFriendProfileEditor import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.AnniversaryUIState import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIState +import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.toModel import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.toUiModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -24,6 +28,7 @@ class FriendProfileEditorViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + private val friendRepository: FriendRepository, ) : ViewModel() { private val routeFriendProfileEditor: RouteFriendProfileEditor = savedStateHandle.toRoute(RouteFriendProfileEditor.routeTypeMap) @@ -134,9 +139,9 @@ class FriendProfileEditorViewModel fun onMemoChanged(value: String?) { value?.length?.let { if (it > MAX_MEMO_LENGTH) { - return + return + } } - } _uiState.update { it.copy( memo = @@ -150,10 +155,33 @@ class FriendProfileEditorViewModel private fun convertMillisToDate(millis: Long): String { val formatter = SimpleDateFormat("yyyy.MM.dd", Locale.getDefault()) - return formatter.format(Date(millis)) - } + return formatter.format(Date(millis)) + } -// fun onSubmit() { + fun onSubmit() { + val updatedFriend = _uiState.value + if (updatedFriend.name.error || updatedFriend.anniversaries.any { it.title.error }) { + // error + return + } + + viewModelScope.launch { + friendRepository + .updateFriend( + friendId = friend.friendId, + friend = + updatedFriend.toModel( + friendId = friend.friendId, + imageUrl = friend.imageUrl ?: "", + phone = friend.phone ?: "", + lastContactAt = friend.lastContactAt ?: "", + ), + ).collect { + } + } + } + + // fun onSubmit() { // val model = _uiState.value // val validated = // model.copy( @@ -186,6 +214,6 @@ class FriendProfileEditorViewModel // } companion object { private const val MAX_NAME_LENGTH = 20 - private const val MAX_MEMO_LENGTH = 200 -} + private const val MAX_MEMO_LENGTH = 200 + } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt index 282a79d7..3d2f2b04 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt @@ -53,3 +53,41 @@ fun Anniversary.toUiModel(): AnniversaryUIState = title = InputField(title), date = InputField(date), ) + +fun FriendProfileEditorUIState.toModel( + friendId: String, + imageUrl: String, + phone: String, + lastContactAt: String, +): Friend = + Friend( + name = name.value, + relation = relation, + contactFrequency = + contactFrequency.copy( + dayOfWeek = + when (contactFrequency.dayOfWeek) { + "월요일" -> "MONDAY" + "화요일" -> "TUESDAY" + "수요일" -> "WEDNESDAY" + "목요일" -> "THURSDAY" + "금요일" -> "FRIDAY" + "토요일" -> "SATURDAY" + "일요일" -> "SUNDAY" + else -> IllegalStateException("없는 타입 입니다.") + } as String, + ), + birthday = birthday.value?.replace(".", "-"), + anniversaryList = + anniversaries.map { + Anniversary( + title = it.title.value, + date = it.date.value?.replace(".", "-"), + ) + }, + friendId = friendId, + imageUrl = imageUrl, + memo = memo.value, + phone = phone, + lastContactAt = lastContactAt, + ) From 77a1c658766e30d1ca39b473e68ce01075f0e02f Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 31 Aug 2025 22:23:03 +0900 Subject: [PATCH 085/100] =?UTF-8?q?feat:=20=EC=9D=B4=EC=A0=84=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../friendprofile/FriendProfileViewModel.kt | 13 +++- .../navigation/FriendProfileNavigation.kt | 23 +++++++- .../FriendProfileEditorScreen.kt | 29 +++++++++ .../FriendProfileEditorViewModel.kt | 59 +++++++------------ .../navigation/FriendProfileNavigation.kt | 4 ++ .../uistate/FriendProfileEditorUIEvent.kt | 17 ++++++ .../presentation/feature/main/NearNavHost.kt | 6 ++ 7 files changed, 110 insertions(+), 41 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIEvent.kt diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt index 4982e655..ec245001 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.alarmy.near.data.repository.FriendRepository +import com.alarmy.near.model.Friend import com.alarmy.near.model.FriendRecord import com.alarmy.near.presentation.feature.friendprofile.navigation.RouteFriendProfile import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendProfileUIEvent @@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.Locale @@ -32,7 +34,8 @@ class FriendProfileViewModel savedStateHandle: SavedStateHandle, private val friendRepository: FriendRepository, ) : ViewModel() { - private val friendId: String = savedStateHandle.toRoute().friendId + private val friendId: String = + savedStateHandle.toRoute().friendId private val _uiEvent = Channel() val uiEvent = _uiEvent.receiveAsFlow() private val _friendFlow: MutableStateFlow = MutableStateFlow(FriendState.Loading) @@ -133,5 +136,11 @@ class FriendProfileViewModel val today = LocalDate.now() val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.KOREA) return today.format(formatter) - } + } + + fun updateFriend(friend: Friend) { + if (_friendFlow.value is FriendState.Success) { + _friendFlow.update { (it as FriendState.Success).copy(friend = friend) } + } + } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt index c28df80c..d22a48d3 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt @@ -1,17 +1,32 @@ package com.alarmy.near.presentation.feature.friendprofile.navigation +import android.os.Build +import android.os.Parcelable +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions +import androidx.navigation.NavType import androidx.navigation.compose.composable +import androidx.navigation.toRoute +import androidx.savedstate.SavedState import com.alarmy.near.model.Friend import com.alarmy.near.presentation.feature.friendprofile.FriendProfileRoute +import com.alarmy.near.presentation.feature.friendprofile.FriendProfileViewModel +import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.FRIEND_PROFILE_EDIT_COMPLETE_KEY +import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.FriendType +import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.RouteFriendProfileEditor +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.reflect.KType +import kotlin.reflect.typeOf +@Parcelize @Serializable data class RouteFriendProfile( val friendId: String, -) +) : Parcelable fun NavController.navigateToFriendProfile( friendId: String, @@ -28,7 +43,13 @@ fun NavGraphBuilder.friendProfileNavGraph( onClickMessageButton: (phoneNumber: String) -> Unit = {}, ) { composable { backStackEntry -> + val viewModel: FriendProfileViewModel = hiltViewModel() + val friend = backStackEntry.savedStateHandle.get(FRIEND_PROFILE_EDIT_COMPLETE_KEY) + friend?.let { + viewModel.updateFriend(it) + } FriendProfileRoute( + viewModel = viewModel, onShowErrorSnackBar = onShowErrorSnackBar, onClickBackButton = onClickBackButton, onEditFriendInfo = onEditFriendInfo, diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index 8aaa835c..9ee0be5d 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -1,5 +1,6 @@ package com.alarmy.near.presentation.feature.friendprofileedittor +import android.util.Log import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -22,6 +23,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -40,10 +42,12 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.Friend import com.alarmy.near.model.Relation import com.alarmy.near.model.ReminderInterval import com.alarmy.near.presentation.feature.friendprofileedittor.component.NearDatePicker import com.alarmy.near.presentation.feature.friendprofileedittor.component.ReminderIntervalBottomSheet +import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIEvent import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIState import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar import com.alarmy.near.presentation.ui.component.radiobutton.NearSmallRadioButton @@ -51,14 +55,39 @@ import com.alarmy.near.presentation.ui.component.textfield.NearLimitedTextField import com.alarmy.near.presentation.ui.component.textfield.NearTextField import com.alarmy.near.presentation.ui.extension.onNoRippleClick import com.alarmy.near.presentation.ui.theme.NearTheme +import kotlinx.coroutines.launch @Composable fun FriendProfileEditorRoute( viewModel: FriendProfileEditorViewModel = hiltViewModel(), onShowErrorSnackBar: (throwable: Throwable?) -> Unit, onClickBackButton: () -> Unit = {}, + onSuccessEdit: (Friend) -> Unit = {}, ) { val friendProfileEditorUIState = viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(viewModel.uiEvent) { + launch { + viewModel.uiEvent.collect { event -> + when (event) { + FriendProfileEditorUIEvent.WarningExit -> { + } + + is FriendProfileEditorUIEvent.FriendProfileEditFailure -> { + onShowErrorSnackBar(event.throwable) + } + + FriendProfileEditorUIEvent.FriendProfileEditNetworkError -> { + onShowErrorSnackBar(IllegalStateException("네트워크 에러가 발생했습니다.")) + } + + is FriendProfileEditorUIEvent.FriendProfileEditSuccess -> { + Log.d("FriendProfileEditorRoute", "FriendProfileEditSuccess") + onSuccessEdit(event.friend) + } + } + } + } + } FriendProfileEditorScreen( onClickBackButton = onClickBackButton, onNameChanged = viewModel::onNameChanged, diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt index 37c88734..d0657435 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt @@ -10,12 +10,16 @@ import com.alarmy.near.model.Relation import com.alarmy.near.model.ReminderInterval import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.RouteFriendProfileEditor import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.AnniversaryUIState +import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIEvent import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIState import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.toModel import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.toUiModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.text.SimpleDateFormat @@ -30,16 +34,16 @@ class FriendProfileEditorViewModel savedStateHandle: SavedStateHandle, private val friendRepository: FriendRepository, ) : ViewModel() { - private val routeFriendProfileEditor: RouteFriendProfileEditor = - savedStateHandle.toRoute(RouteFriendProfileEditor.routeTypeMap) private val friend: Friend = - routeFriendProfileEditor.friend.apply { - copy() - } + savedStateHandle.toRoute(RouteFriendProfileEditor.routeTypeMap).friend + private val _uiState: MutableStateFlow = MutableStateFlow(friend.toUiModel()) val uiState = _uiState.asStateFlow() + private val _uiEvent = Channel() + val uiEvent = _uiEvent.receiveAsFlow() + fun onNameChanged(value: String) { if (value.length > MAX_NAME_LENGTH) { return @@ -158,9 +162,17 @@ class FriendProfileEditorViewModel return formatter.format(Date(millis)) } + fun onExit() { + if (uiState.value.anniversaries.any { it.title.isDirty || it.date.isDirty } || + uiState.value.name.isDirty || uiState.value.memo.isDirty || + uiState.value.contactFrequency != friend.contactFrequency || uiState.value.birthday.isDirty + ) { + } + } + fun onSubmit() { val updatedFriend = _uiState.value - if (updatedFriend.name.error || updatedFriend.anniversaries.any { it.title.error }) { + if ((updatedFriend.name.error || updatedFriend.anniversaries.any { it.title.error })) { // error return } @@ -176,42 +188,13 @@ class FriendProfileEditorViewModel phone = friend.phone ?: "", lastContactAt = friend.lastContactAt ?: "", ), - ).collect { + ).catch { + }.collect { + _uiEvent.send(FriendProfileEditorUIEvent.FriendProfileEditSuccess(it)) } } } - // fun onSubmit() { -// val model = _uiState.value -// val validated = -// model.copy( -// name = model.name.copy(error = if (model.name.value.isBlank()) "이름을 입력해주세요." else null), -// anniversaries = -// model.anniversaries.map { anniversary -> -// anniversary.copy( -// title = anniversary.title.copy(error = if (anniversary.title.value.isBlank()) "기념일 이름을 입력해주세요." else null), -// date = anniversary.date.copy(error = if (anniversary.date.value == null) "날짜를 선택해주세요." else null), -// ) -// }, -// ) -// -// // _uiState.update { it.copy( = validated) } -// -// // Validation 성공 시 Repository 저장 -// if (validated.name.error == null && -// validated.anniversaries.all { it.title.error == null && it.date.error == null } -// ) { -// _uiState.update { it.copy(isSubmitting = true) } -// viewModelScope.launch { -// // repository.save(validated.toDomain()) -// _uiState.update { it.copy(isSubmitting = false, isSuccess = true) } -// } -// } -// } -// -// private fun update(transform: FriendUiModel.() -> FriendUiModel) { -// _uiState.update { state -> state.copy(model = state.model.transform()) } -// } companion object { private const val MAX_NAME_LENGTH = 20 private const val MAX_MEMO_LENGTH = 200 diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt index 737f9814..1fd39a14 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt @@ -17,6 +17,8 @@ import kotlinx.serialization.json.Json import kotlin.reflect.KType import kotlin.reflect.typeOf +const val FRIEND_PROFILE_EDIT_COMPLETE_KEY = "FRIEND_PROFILE_EDIT_COMPLETE_KEY" + @Serializable @Parcelize data class RouteFriendProfileEditor( @@ -45,6 +47,7 @@ fun NavController.navigateToFriendProfileEditor( fun NavGraphBuilder.friendProfileEditorNavGraph( onShowErrorSnackBar: (throwable: Throwable?) -> Unit, onClickBackButton: () -> Unit = {}, + onSuccessEdit: (Friend) -> Unit = {}, ) { composable( typeMap = @@ -53,6 +56,7 @@ fun NavGraphBuilder.friendProfileEditorNavGraph( FriendProfileEditorRoute( onShowErrorSnackBar = onShowErrorSnackBar, onClickBackButton = onClickBackButton, + onSuccessEdit = onSuccessEdit, ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIEvent.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIEvent.kt new file mode 100644 index 00000000..a8ffe2c7 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIEvent.kt @@ -0,0 +1,17 @@ +package com.alarmy.near.presentation.feature.friendprofileedittor.uistate + +import com.alarmy.near.model.Friend + +sealed interface FriendProfileEditorUIEvent { + data class FriendProfileEditSuccess( + val friend: Friend, + ) : FriendProfileEditorUIEvent + + data class FriendProfileEditFailure( + val throwable: Throwable, + ) : FriendProfileEditorUIEvent + + data object FriendProfileEditNetworkError : FriendProfileEditorUIEvent + + data object WarningExit : FriendProfileEditorUIEvent +} 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 331498de..0444453d 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 @@ -9,6 +9,7 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import com.alarmy.near.presentation.feature.friendprofile.navigation.friendProfileNavGraph import com.alarmy.near.presentation.feature.friendprofile.navigation.navigateToFriendProfile +import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.FRIEND_PROFILE_EDIT_COMPLETE_KEY import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.friendProfileEditorNavGraph import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.navigateToFriendProfileEditor import com.alarmy.near.presentation.feature.home.navigation.RouteHome @@ -50,6 +51,11 @@ internal fun NearNavHost( navController.navigateToFriendProfileEditor(friend = it) }) friendProfileEditorNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = { + }, onSuccessEdit = { + navController.previousBackStackEntry?.savedStateHandle?.set( + FRIEND_PROFILE_EDIT_COMPLETE_KEY, + it, + ) navController.popBackStack() }) // 로그인 화면 NavGraph From b6dfdff9732e3c002828e32283b1d297f1fb8578 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 31 Aug 2025 22:27:01 +0900 Subject: [PATCH 086/100] =?UTF-8?q?fix:=20navigation=20imageUrl=20?= =?UTF-8?q?=EC=9D=B8=EC=BD=94=EB=94=A9=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../near/presentation/feature/main/NearNavHost.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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 0444453d..8ba5ef24 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 @@ -14,6 +14,8 @@ 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.RouteHome import com.alarmy.near.presentation.feature.home.navigation.homeNavGraph +import java.net.URLEncoder +import java.nio.charset.StandardCharsets import com.alarmy.near.presentation.feature.home.navigation.navigateToHome import com.alarmy.near.presentation.feature.login.navigation.RouteLogin import com.alarmy.near.presentation.feature.login.navigation.loginNavGraph @@ -48,7 +50,18 @@ internal fun NearNavHost( } context.startActivity(intent) }, onEditFriendInfo = { - navController.navigateToFriendProfileEditor(friend = it) + navController.navigateToFriendProfileEditor( + friend = + it.copy( + imageUrl = + it.imageUrl?.let { imageUrl -> + URLEncoder.encode( + imageUrl, + StandardCharsets.UTF_8.toString(), + ) + }, + ), + ) }) friendProfileEditorNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = { }, onSuccessEdit = { From 5a0a84ca2d97b72275d68537c9c2be9700aecd10 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Mon, 1 Sep 2025 00:18:14 +0900 Subject: [PATCH 087/100] =?UTF-8?q?feat:=20=ED=99=94=EB=A9=B4=EC=9D=B4=20?= =?UTF-8?q?=EA=B8=B8=EC=96=B4=EC=A7=88=20=EA=B2=BD=EC=9A=B0=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../friendprofile/FriendProfileScreen.kt | 5 + .../FriendProfileEditorScreen.kt | 740 +++++++++--------- 2 files changed, 377 insertions(+), 368 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index a7eec3b5..73de2748 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -24,7 +24,9 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -151,12 +153,14 @@ fun FriendProfileScreen( ) { when (friendState) { is FriendState.Success -> { + val scrollState = rememberScrollState() val friend = friendState.friend Column( modifier = Modifier .align(Alignment.TopStart) .fillMaxSize() + .verticalScroll(scrollState) .background(NearTheme.colors.WHITE_FFFFFF), ) { if (recordSuccessDialogState) { @@ -397,6 +401,7 @@ fun FriendProfileScreen( } else { RecordTab(friendShipRecordState = friendShipRecordState) } + Spacer(modifier = Modifier.height(60.dp)) } NearSolidTypeButton( modifier = diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index 9ee0be5d..c78c0708 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -132,441 +132,445 @@ fun FriendProfileEditorScreen( showBottomSheet.value = false }) } - Column( + LazyColumn( modifier = - modifier + Modifier .fillMaxSize() .background(NearTheme.colors.WHITE_FFFFFF), ) { - Spacer(modifier = Modifier.padding(top = statusBarHeightDp)) - NearTopAppbar( - modifier = Modifier.padding(end = 24.dp), - title = "", - onClickBackButton = onClickBackButton, - menuButton = { - Text( - modifier = - Modifier.onNoRippleClick(onClick = { - onSubmit() - }), - text = "완료", - style = NearTheme.typography.B1_16_BOLD, - color = NearTheme.colors.BLACK_1A1A1A, - ) - }, - ) - Spacer(modifier = Modifier.height(16.dp)) - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(start = 24.dp, end = 20.dp), - ) { - Row( - modifier = - Modifier - .fillMaxWidth(), - ) { - Text( - modifier = Modifier.padding(top = 16.dp), - text = - buildAnnotatedString { - append("이름") - withStyle( - style = - SpanStyle( - color = NearTheme.colors.BLUE01_5AA2E9, - ), - ) { - append("*") - } - }, - textAlign = TextAlign.Center, - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(55.dp)) - NearTextField( - modifier = Modifier.weight(1f), - value = friendProfileEditorUIState.name.value, - onValueChange = { - onNameChanged(it) + if (friendProfileEditorUIState.anniversaries.isNotEmpty()) { + item { + Spacer(modifier = Modifier.padding(top = statusBarHeightDp)) + NearTopAppbar( + modifier = Modifier.padding(end = 24.dp), + title = "", + onClickBackButton = onClickBackButton, + menuButton = { + Text( + modifier = + Modifier.onNoRippleClick(onClick = { + onSubmit() + }), + text = "완료", + style = NearTheme.typography.B1_16_BOLD, + color = NearTheme.colors.BLACK_1A1A1A, + ) }, ) - } - if (friendProfileEditorUIState.name.error) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - "이름을 입력해주세요.", - style = NearTheme.typography.FC_12_MEDIUM, - color = NearTheme.colors.NEGATIVE_F04E4E, - ) - } - Spacer(modifier = Modifier.height(32.dp)) - Row( - modifier = - Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "관계", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(72.dp)) - Row( + Spacer(modifier = Modifier.height(16.dp)) + Column( modifier = Modifier - .weight(1f) - .padding(end = 35.dp), - horizontalArrangement = Arrangement.SpaceBetween, + .fillMaxWidth() + .padding(start = 24.dp, end = 20.dp), ) { Row( modifier = - Modifier.onNoRippleClick(onClick = { - onRelationChanged(Relation.FRIEND) - }), - verticalAlignment = Alignment.CenterVertically, + Modifier + .fillMaxWidth(), ) { - NearSmallRadioButton( - selected = friendProfileEditorUIState.relation == Relation.FRIEND, - onClick = {}, - ) - Spacer(modifier = Modifier.width(8.dp)) Text( - text = stringResource(R.string.friend_profile_editor_relation_freind), + modifier = Modifier.padding(top = 16.dp), + text = + buildAnnotatedString { + append("이름") + withStyle( + style = + SpanStyle( + color = NearTheme.colors.BLUE01_5AA2E9, + ), + ) { + append("*") + } + }, + textAlign = TextAlign.Center, style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - } - Row( - modifier = - Modifier.onNoRippleClick(onClick = { - onRelationChanged(Relation.FAMILY) - }), - verticalAlignment = Alignment.CenterVertically, - ) { - NearSmallRadioButton( - selected = friendProfileEditorUIState.relation == Relation.FAMILY, - onClick = {}, + color = NearTheme.colors.GRAY01_888888, ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.friend_profile_editor_relation_family), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, + Spacer(modifier = Modifier.width(55.dp)) + NearTextField( + modifier = Modifier.weight(1f), + value = friendProfileEditorUIState.name.value, + onValueChange = { + onNameChanged(it) + }, ) } - Row(verticalAlignment = Alignment.CenterVertically) { - NearSmallRadioButton( - selected = friendProfileEditorUIState.relation == Relation.ACQUAINTANCE, - onClick = { onRelationChanged(Relation.ACQUAINTANCE) }, - ) - Spacer(modifier = Modifier.width(8.dp)) + if (friendProfileEditorUIState.name.error) { + Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(R.string.friend_profile_editor_relation_acquaintance), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, + "이름을 입력해주세요.", + style = NearTheme.typography.FC_12_MEDIUM, + color = NearTheme.colors.NEGATIVE_F04E4E, ) } - } - } - Spacer(modifier = Modifier.height(33.dp)) - Row( - modifier = - Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "연락 주기", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(35.dp)) - Surface( - modifier = - modifier - .weight(1f) - .onNoRippleClick({ - showBottomSheet.value = true - }), - shape = RoundedCornerShape(12.dp), - border = - BorderStroke( - width = 1.dp, - color = NearTheme.colors.GRAY03_EBEBEB, - ), - color = NearTheme.colors.WHITE_FFFFFF, - ) { + Spacer(modifier = Modifier.height(32.dp)) Row( modifier = - Modifier.padding( - start = 16.dp, - end = 12.dp, - top = 14.dp, - bottom = 14.dp, - ), + Modifier + .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = - stringResource(friendProfileEditorUIState.contactFrequency.reminderInterval.labelRes) + - "(${friendProfileEditorUIState.contactFrequency.dayOfWeek} 마다)", + "관계", style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - Image( - painter = painterResource(id = R.drawable.ic_24_down), - contentDescription = null, + color = NearTheme.colors.GRAY01_888888, ) - } - } - } - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = - Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - val birthdayDatePickerState = remember { mutableStateOf(false) } - val datePickerState = - rememberDatePickerState() - if (birthdayDatePickerState.value) { - NearDatePicker( - datePickerState = datePickerState, - onDismiss = { birthdayDatePickerState.value = false }, - onDateSelected = { - it?.let { - onBirthdayChanged(it) + Spacer(modifier = Modifier.width(72.dp)) + Row( + modifier = + Modifier + .weight(1f) + .padding(end = 35.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = + Modifier.onNoRippleClick(onClick = { + onRelationChanged(Relation.FRIEND) + }), + verticalAlignment = Alignment.CenterVertically, + ) { + NearSmallRadioButton( + selected = friendProfileEditorUIState.relation == Relation.FRIEND, + onClick = {}, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.friend_profile_editor_relation_freind), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) } - }, - ) - } - Text( - "생일", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(62.dp)) - Surface( - modifier = - modifier - .weight(1f), - shape = RoundedCornerShape(12.dp), - border = - BorderStroke( - width = 1.dp, - color = NearTheme.colors.GRAY03_EBEBEB, - ), - color = NearTheme.colors.WHITE_FFFFFF, - ) { + Row( + modifier = + Modifier.onNoRippleClick(onClick = { + onRelationChanged(Relation.FAMILY) + }), + verticalAlignment = Alignment.CenterVertically, + ) { + NearSmallRadioButton( + selected = friendProfileEditorUIState.relation == Relation.FAMILY, + onClick = {}, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.friend_profile_editor_relation_family), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + NearSmallRadioButton( + selected = friendProfileEditorUIState.relation == Relation.ACQUAINTANCE, + onClick = { onRelationChanged(Relation.ACQUAINTANCE) }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.friend_profile_editor_relation_acquaintance), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + } + } + } + Spacer(modifier = Modifier.height(33.dp)) Row( modifier = Modifier - .padding( - start = 16.dp, - end = 12.dp, - top = 14.dp, - bottom = 14.dp, - ).onNoRippleClick({ - birthdayDatePickerState.value = true - }), + .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - friendProfileEditorUIState.birthday.value ?: "날짜 선택", + "연락 주기", style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - Image( - painter = painterResource(id = R.drawable.ic_24_down), - contentDescription = null, + color = NearTheme.colors.GRAY01_888888, ) + Spacer(modifier = Modifier.width(35.dp)) + Surface( + modifier = + modifier + .weight(1f) + .onNoRippleClick({ + showBottomSheet.value = true + }), + shape = RoundedCornerShape(12.dp), + border = + BorderStroke( + width = 1.dp, + color = NearTheme.colors.GRAY03_EBEBEB, + ), + color = NearTheme.colors.WHITE_FFFFFF, + ) { + Row( + modifier = + Modifier.padding( + start = 16.dp, + end = 12.dp, + top = 14.dp, + bottom = 14.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = + stringResource(friendProfileEditorUIState.contactFrequency.reminderInterval.labelRes) + + "(${friendProfileEditorUIState.contactFrequency.dayOfWeek} 마다)", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + Image( + painter = painterResource(id = R.drawable.ic_24_down), + contentDescription = null, + ) + } + } } - } - } - Spacer(modifier = Modifier.height(32.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "기념일", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Text( - modifier = - Modifier.onNoRippleClick(onClick = { - onAddAnniversary() - }), - text = "추가하기", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLUE01_5AA2E9, - ) - } - Spacer(modifier = Modifier.height(16.dp)) - } - if (friendProfileEditorUIState.anniversaries.isNotEmpty()) { - LazyColumn( - modifier = Modifier.background(color = NearTheme.colors.BG02_F4F9FD), - contentPadding = - PaddingValues( - top = 20.dp, - bottom = 32.dp, - start = 24.dp, - end = 20.dp, - ), - verticalArrangement = Arrangement.spacedBy(32.dp), - ) { - items( - count = friendProfileEditorUIState.anniversaries.size, - ) { index -> - Column { - val anniversaryDatePickerState = remember { mutableStateOf(false) } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = + Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + val birthdayDatePickerState = remember { mutableStateOf(false) } val datePickerState = rememberDatePickerState() - if (anniversaryDatePickerState.value) { + if (birthdayDatePickerState.value) { NearDatePicker( datePickerState = datePickerState, - onDismiss = { anniversaryDatePickerState.value = false }, + onDismiss = { birthdayDatePickerState.value = false }, onDateSelected = { it?.let { - onAnniversaryDateSelected(index, it) + onBirthdayChanged(it) } }, ) } - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "기념일 이름", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(23.dp)) - NearTextField( - modifier = Modifier.weight(1f), - value = friendProfileEditorUIState.anniversaries[index].title.value, - onValueChange = { - onAnniversaryNameChange(index, it) - }, - ) - } - if (friendProfileEditorUIState.anniversaries[index].title.error) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - "이름을 입력해주세요.", - style = NearTheme.typography.FC_12_MEDIUM, - color = NearTheme.colors.NEGATIVE_F04E4E, - ) - } - Spacer(modifier = Modifier.height(16.dp)) - Row( + Text( + "생일", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(62.dp)) + Surface( modifier = - Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, + modifier + .weight(1f), + shape = RoundedCornerShape(12.dp), + border = + BorderStroke( + width = 1.dp, + color = NearTheme.colors.GRAY03_EBEBEB, + ), + color = NearTheme.colors.WHITE_FFFFFF, ) { - Text( - "날짜", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(62.dp)) - Surface( + Row( modifier = - modifier - .weight(1f) - .onNoRippleClick(onClick = { - anniversaryDatePickerState.value = true - }), - shape = RoundedCornerShape(12.dp), - border = - BorderStroke( - width = 1.dp, - color = NearTheme.colors.GRAY03_EBEBEB, - ), - color = NearTheme.colors.WHITE_FFFFFF, - ) { - Row( - modifier = - Modifier.padding( + Modifier + .padding( start = 16.dp, end = 12.dp, top = 14.dp, bottom = 14.dp, - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - friendProfileEditorUIState.anniversaries[index].date.value - ?: "날짜 선택", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - Image( - painter = painterResource(id = R.drawable.ic_24_down), - contentDescription = null, - ) - } + ).onNoRippleClick({ + birthdayDatePickerState.value = true + }), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + friendProfileEditorUIState.birthday.value ?: "날짜 선택", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + Image( + painter = painterResource(id = R.drawable.ic_24_down), + contentDescription = null, + ) } } - Spacer(modifier = Modifier.height(32.dp)) + } + Spacer(modifier = Modifier.height(32.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "기념일", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) Text( modifier = - Modifier - .fillMaxWidth() - .onNoRippleClick( - onClick = { - onRemoveAnniversary(index) - }, - ), - textAlign = TextAlign.End, - text = "삭제하기", - textDecoration = TextDecoration.Underline, + Modifier.onNoRippleClick(onClick = { + onAddAnniversary() + }), + text = "추가하기", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLUE01_5AA2E9, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + } + items( + count = friendProfileEditorUIState.anniversaries.size, + ) { index -> + Column( + modifier = + Modifier + .background(color = NearTheme.colors.BG02_F4F9FD) + .padding( + PaddingValues( + top = 20.dp, + bottom = 32.dp, + start = 24.dp, + end = 20.dp, + ), + ), + ) { + val anniversaryDatePickerState = remember { mutableStateOf(false) } + val datePickerState = + rememberDatePickerState() + if (anniversaryDatePickerState.value) { + NearDatePicker( + datePickerState = datePickerState, + onDismiss = { anniversaryDatePickerState.value = false }, + onDateSelected = { + it?.let { + onAnniversaryDateSelected(index, it) + } + }, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "기념일 이름", + style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.GRAY01_888888, ) + Spacer(modifier = Modifier.width(23.dp)) + NearTextField( + modifier = Modifier.weight(1f), + value = friendProfileEditorUIState.anniversaries[index].title.value, + onValueChange = { + onAnniversaryNameChange(index, it) + }, + ) + } + if (friendProfileEditorUIState.anniversaries[index].title.error) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + "이름을 입력해주세요.", + style = NearTheme.typography.FC_12_MEDIUM, + color = NearTheme.colors.NEGATIVE_F04E4E, + ) } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = + Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "날짜", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(62.dp)) + Surface( + modifier = + modifier + .weight(1f) + .onNoRippleClick(onClick = { + anniversaryDatePickerState.value = true + }), + shape = RoundedCornerShape(12.dp), + border = + BorderStroke( + width = 1.dp, + color = NearTheme.colors.GRAY03_EBEBEB, + ), + color = NearTheme.colors.WHITE_FFFFFF, + ) { + Row( + modifier = + Modifier.padding( + start = 16.dp, + end = 12.dp, + top = 14.dp, + bottom = 14.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + friendProfileEditorUIState.anniversaries[index].date.value + ?: "날짜 선택", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + Image( + painter = painterResource(id = R.drawable.ic_24_down), + contentDescription = null, + ) + } + } + } + Spacer(modifier = Modifier.height(32.dp)) + Text( + modifier = + Modifier + .fillMaxWidth() + .onNoRippleClick( + onClick = { + onRemoveAnniversary(index) + }, + ), + textAlign = TextAlign.End, + text = "삭제하기", + textDecoration = TextDecoration.Underline, + color = NearTheme.colors.GRAY01_888888, + ) } } } - Spacer(modifier = Modifier.height(24.dp)) - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), - ) { - Text( - modifier = Modifier.padding(top = 16.dp), - text = "메모", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(23.dp)) - NearLimitedTextField( + item { + Spacer(modifier = Modifier.height(24.dp)) + Row( modifier = Modifier - .weight(1f) - .height(180.dp), - value = friendProfileEditorUIState.memo.value ?: "", - onValueChange = { - onMemoChanged(it) - }, - placeHolderText = - "꼭 기억해야 할 내용을 기록해보세요.\n" + - "예) 날생선 X, 작년 생일에\n" + - "키링 선물함 등", - maxTextCount = 100, - ) + .fillMaxWidth() + .padding(horizontal = 24.dp), + ) { + Text( + modifier = Modifier.padding(top = 16.dp), + text = "메모", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(23.dp)) + NearLimitedTextField( + modifier = + Modifier + .weight(1f) + .height(180.dp), + value = friendProfileEditorUIState.memo.value ?: "", + onValueChange = { + onMemoChanged(it) + }, + placeHolderText = + "꼭 기억해야 할 내용을 기록해보세요.\n" + + "예) 날생선 X, 작년 생일에\n" + + "키링 선물함 등", + maxTextCount = 100, + ) + } + Spacer(modifier = Modifier.height(80.dp)) } - Spacer(modifier = Modifier.height(80.dp)) } } From 078384bb5e437d6fdfb68b598f6ce52e3cfb2c7b Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Mon, 1 Sep 2025 00:37:31 +0900 Subject: [PATCH 088/100] =?UTF-8?q?feat:=20=EC=88=98=EC=A0=95=EC=8B=9C=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=A1=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../friendprofile/FriendProfileScreen.kt | 4 +-- .../FriendProfileEditorScreen.kt | 30 ++++++++++++++++--- .../FriendProfileEditorViewModel.kt | 13 +++++--- .../uistate/FriendProfileEditorUIEvent.kt | 2 ++ .../presentation/feature/main/NearNavHost.kt | 1 + 5 files changed, 39 insertions(+), 11 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index 73de2748..3bfe7e7a 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -153,14 +153,12 @@ fun FriendProfileScreen( ) { when (friendState) { is FriendState.Success -> { - val scrollState = rememberScrollState() val friend = friendState.friend Column( modifier = Modifier .align(Alignment.TopStart) .fillMaxSize() - .verticalScroll(scrollState) .background(NearTheme.colors.WHITE_FFFFFF), ) { if (recordSuccessDialogState) { @@ -496,7 +494,7 @@ private fun RecordTab( } else { Spacer(modifier = Modifier.height(13.dp)) LazyVerticalGrid( - GridCells.Fixed(3), + columns = GridCells.Fixed(3), verticalArrangement = Arrangement.spacedBy(24.dp), contentPadding = PaddingValues(bottom = 60.dp), ) { diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index c78c0708..0a86ae91 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -1,6 +1,5 @@ package com.alarmy.near.presentation.feature.friendprofileedittor -import android.util.Log import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -47,6 +46,7 @@ import com.alarmy.near.model.Relation import com.alarmy.near.model.ReminderInterval import com.alarmy.near.presentation.feature.friendprofileedittor.component.NearDatePicker import com.alarmy.near.presentation.feature.friendprofileedittor.component.ReminderIntervalBottomSheet +import com.alarmy.near.presentation.feature.friendprofileedittor.dialog.EditorExitDialog import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIEvent import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIState import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar @@ -65,11 +65,18 @@ fun FriendProfileEditorRoute( onSuccessEdit: (Friend) -> Unit = {}, ) { val friendProfileEditorUIState = viewModel.uiState.collectAsStateWithLifecycle() + val warningDialogState = remember { mutableStateOf(false) } LaunchedEffect(viewModel.uiEvent) { launch { viewModel.uiEvent.collect { event -> when (event) { FriendProfileEditorUIEvent.WarningExit -> { + warningDialogState.value = true + } + + FriendProfileEditorUIEvent.Exit -> { + warningDialogState.value = false + onClickBackButton() } is FriendProfileEditorUIEvent.FriendProfileEditFailure -> { @@ -81,7 +88,6 @@ fun FriendProfileEditorRoute( } is FriendProfileEditorUIEvent.FriendProfileEditSuccess -> { - Log.d("FriendProfileEditorRoute", "FriendProfileEditSuccess") onSuccessEdit(event.friend) } } @@ -89,9 +95,10 @@ fun FriendProfileEditorRoute( } } FriendProfileEditorScreen( - onClickBackButton = onClickBackButton, - onNameChanged = viewModel::onNameChanged, friendProfileEditorUIState = friendProfileEditorUIState.value, + dialogState = warningDialogState.value, + onClickBackButton = viewModel::onExit, + onNameChanged = viewModel::onNameChanged, onRelationChanged = viewModel::onRelationChanged, onReminderIntervalChanged = viewModel::onRemindIntervalChanged, onBirthdayChanged = viewModel::onBirthdayChanged, @@ -101,6 +108,8 @@ fun FriendProfileEditorRoute( onAddAnniversary = viewModel::onAddAnniversary, onMemoChanged = viewModel::onMemoChanged, onSubmit = viewModel::onSubmit, + onEditorExit = onClickBackButton, + onCloseDialog = { warningDialogState.value = false }, ) } @@ -108,6 +117,7 @@ fun FriendProfileEditorRoute( @Composable fun FriendProfileEditorScreen( modifier: Modifier = Modifier, + dialogState: Boolean = false, friendProfileEditorUIState: FriendProfileEditorUIState, onClickBackButton: () -> Unit = {}, onNameChanged: (String) -> Unit = {}, @@ -120,6 +130,8 @@ fun FriendProfileEditorScreen( onAddAnniversary: () -> Unit = {}, onMemoChanged: (String) -> Unit = {}, onSubmit: () -> Unit = {}, + onEditorExit: () -> Unit = {}, + onCloseDialog: () -> Unit = {}, ) { val density = LocalDensity.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } @@ -132,6 +144,16 @@ fun FriendProfileEditorScreen( showBottomSheet.value = false }) } + if (dialogState) { + EditorExitDialog( + onDismissRequest = { + onCloseDialog() + }, + onConfirm = { + onEditorExit() + }, + ) + } LazyColumn( modifier = Modifier diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt index d0657435..51b7d801 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt @@ -163,10 +163,15 @@ class FriendProfileEditorViewModel } fun onExit() { - if (uiState.value.anniversaries.any { it.title.isDirty || it.date.isDirty } || - uiState.value.name.isDirty || uiState.value.memo.isDirty || - uiState.value.contactFrequency != friend.contactFrequency || uiState.value.birthday.isDirty - ) { + viewModelScope.launch { + if (uiState.value.anniversaries.any { it.title.isDirty || it.date.isDirty } || + uiState.value.name.isDirty || uiState.value.memo.isDirty || + uiState.value.birthday.isDirty + ) { + _uiEvent.send(FriendProfileEditorUIEvent.WarningExit) + } else { + _uiEvent.send(FriendProfileEditorUIEvent.Exit) + } } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIEvent.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIEvent.kt index a8ffe2c7..bf84abfa 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIEvent.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIEvent.kt @@ -14,4 +14,6 @@ sealed interface FriendProfileEditorUIEvent { data object FriendProfileEditNetworkError : FriendProfileEditorUIEvent data object WarningExit : FriendProfileEditorUIEvent + + data object Exit : FriendProfileEditorUIEvent } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt index 8ba5ef24..adf33e39 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt @@ -64,6 +64,7 @@ internal fun NearNavHost( ) }) friendProfileEditorNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = { + navController.popBackStack() }, onSuccessEdit = { navController.previousBackStackEntry?.savedStateHandle?.set( FRIEND_PROFILE_EDIT_COMPLETE_KEY, From 70460652926cabbb7eb104dd4d8e747b4ca57cbd Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Mon, 1 Sep 2025 00:40:30 +0900 Subject: [PATCH 089/100] =?UTF-8?q?fix:=20=EC=B9=9C=EA=B5=AC=20=EA=B4=80?= =?UTF-8?q?=EA=B3=84=20=EB=B2=84=ED=8A=BC=20=ED=81=B4=EB=A6=AD=20=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FriendProfileEditorScreen.kt | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index 0a86ae91..7877f281 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -247,15 +247,13 @@ fun FriendProfileEditorScreen( horizontalArrangement = Arrangement.SpaceBetween, ) { Row( - modifier = - Modifier.onNoRippleClick(onClick = { - onRelationChanged(Relation.FRIEND) - }), verticalAlignment = Alignment.CenterVertically, ) { NearSmallRadioButton( selected = friendProfileEditorUIState.relation == Relation.FRIEND, - onClick = {}, + onClick = { + onRelationChanged(Relation.FRIEND) + }, ) Spacer(modifier = Modifier.width(8.dp)) Text( @@ -265,15 +263,13 @@ fun FriendProfileEditorScreen( ) } Row( - modifier = - Modifier.onNoRippleClick(onClick = { - onRelationChanged(Relation.FAMILY) - }), verticalAlignment = Alignment.CenterVertically, ) { NearSmallRadioButton( selected = friendProfileEditorUIState.relation == Relation.FAMILY, - onClick = {}, + onClick = { + onRelationChanged(Relation.FAMILY) + }, ) Spacer(modifier = Modifier.width(8.dp)) Text( From f2807ab754769fd4a854c9e195834b8086faf1c8 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Mon, 1 Sep 2025 00:50:44 +0900 Subject: [PATCH 090/100] =?UTF-8?q?fix:=20=EC=B1=94=EA=B9=80=EC=88=9C?= =?UTF-8?q?=EC=84=9C=20=EC=B5=9C=EC=8B=A0=EC=88=9C=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/friendprofile/FriendProfileScreen.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index 3bfe7e7a..f08e9661 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -24,9 +24,7 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -499,7 +497,11 @@ private fun RecordTab( contentPadding = PaddingValues(bottom = 60.dp), ) { items(friendShipRecordState.records.size) { - RecordItem(friendRecord = friendShipRecordState.records[it], index = it) + RecordItem( + friendRecord = friendShipRecordState.records[friendShipRecordState.records.size - 1 - it], + index = + friendShipRecordState.records.size - it, + ) } } } @@ -541,7 +543,7 @@ private fun RecordItem( contentDescription = null, ) Text( - "${index + 1}번째 챙김", + "${index}번째 챙김", style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLUE01_5AA2E9, ) From 80a0b872ed4459e0005612380a2bb57f2d80bdb6 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Mon, 1 Sep 2025 22:23:28 +0900 Subject: [PATCH 091/100] =?UTF-8?q?refactor:=20string=20res=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../friendprofile/FriendProfileScreen.kt | 10 +- .../FriendProfileEditorScreen.kt | 502 +++++++++--------- .../FriendProfileEditorViewModel.kt | 2 +- Near/app/src/main/res/values/strings.xml | 20 +- 4 files changed, 277 insertions(+), 257 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index f08e9661..16adc03f 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -185,7 +185,7 @@ fun FriendProfileScreen( ) Spacer(modifier = Modifier.height(8.dp)) Text( - "더 가까워졌어요!", + stringResource(R.string.friend_profile_info_contact_success_text), style = NearTheme.typography.B1_16_BOLD, color = Color(0xff222222), ) @@ -219,7 +219,7 @@ fun FriendProfileScreen( }, text = { Text( - "수정", + stringResource(R.string.friend_profile_info_edit), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLACK_1A1A1A, ) @@ -232,7 +232,7 @@ fun FriendProfileScreen( }, text = { Text( - "삭제", + stringResource(R.string.friend_profile_info_delete), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLACK_1A1A1A, ) @@ -484,7 +484,7 @@ private fun RecordTab( Image(painterResource(R.drawable.img_100_character_empty), contentDescription = null) Spacer(modifier = Modifier.height(16.dp)) Text( - "이번달은 챙길 사람이 없네요.", + stringResource(R.string.friend_profile_info_empty_contact_friend), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.GRAY01_888888, ) @@ -543,7 +543,7 @@ private fun RecordItem( contentDescription = null, ) Text( - "${index}번째 챙김", + stringResource(R.string.friend_profile_info_contact_record_text, index), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLUE01_5AA2E9, ) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index 7877f281..f7a03878 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -160,279 +160,283 @@ fun FriendProfileEditorScreen( .fillMaxSize() .background(NearTheme.colors.WHITE_FFFFFF), ) { - if (friendProfileEditorUIState.anniversaries.isNotEmpty()) { - item { - Spacer(modifier = Modifier.padding(top = statusBarHeightDp)) - NearTopAppbar( - modifier = Modifier.padding(end = 24.dp), - title = "", - onClickBackButton = onClickBackButton, - menuButton = { - Text( - modifier = - Modifier.onNoRippleClick(onClick = { - onSubmit() - }), - text = "완료", - style = NearTheme.typography.B1_16_BOLD, - color = NearTheme.colors.BLACK_1A1A1A, - ) - }, - ) - Spacer(modifier = Modifier.height(16.dp)) - Column( + item { + Spacer(modifier = Modifier.padding(top = statusBarHeightDp)) + NearTopAppbar( + modifier = Modifier.padding(end = 24.dp), + title = "", + onClickBackButton = onClickBackButton, + menuButton = { + Text( + modifier = + Modifier.onNoRippleClick(onClick = { + onSubmit() + }), + text = "완료", + style = NearTheme.typography.B1_16_BOLD, + color = NearTheme.colors.BLACK_1A1A1A, + ) + }, + ) + Spacer(modifier = Modifier.height(16.dp)) + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 20.dp), + ) { + Row( modifier = Modifier - .fillMaxWidth() - .padding(start = 24.dp, end = 20.dp), + .fillMaxWidth(), ) { + Text( + modifier = Modifier.padding(top = 16.dp), + text = + buildAnnotatedString { + append("이름") + withStyle( + style = + SpanStyle( + color = NearTheme.colors.BLUE01_5AA2E9, + ), + ) { + append("*") + } + }, + textAlign = TextAlign.Center, + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(55.dp)) + NearTextField( + modifier = Modifier.weight(1f), + value = friendProfileEditorUIState.name.value, + onValueChange = { + onNameChanged(it) + }, + ) + } + if (friendProfileEditorUIState.name.error) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + "이름을 입력해주세요.", + style = NearTheme.typography.FC_12_MEDIUM, + color = NearTheme.colors.NEGATIVE_F04E4E, + ) + } + Spacer(modifier = Modifier.height(32.dp)) + Row( + modifier = + Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "관계", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(72.dp)) Row( modifier = Modifier - .fillMaxWidth(), + .weight(1f) + .padding(end = 35.dp), + horizontalArrangement = Arrangement.SpaceBetween, ) { - Text( - modifier = Modifier.padding(top = 16.dp), - text = - buildAnnotatedString { - append("이름") - withStyle( - style = - SpanStyle( - color = NearTheme.colors.BLUE01_5AA2E9, - ), - ) { - append("*") - } + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + NearSmallRadioButton( + selected = friendProfileEditorUIState.relation == Relation.FRIEND, + onClick = { + onRelationChanged(Relation.FRIEND) }, - textAlign = TextAlign.Center, - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(55.dp)) - NearTextField( - modifier = Modifier.weight(1f), - value = friendProfileEditorUIState.name.value, - onValueChange = { - onNameChanged(it) - }, - ) - } - if (friendProfileEditorUIState.name.error) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - "이름을 입력해주세요.", - style = NearTheme.typography.FC_12_MEDIUM, - color = NearTheme.colors.NEGATIVE_F04E4E, - ) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.friend_profile_editor_relation_friend), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + NearSmallRadioButton( + selected = friendProfileEditorUIState.relation == Relation.FAMILY, + onClick = { + onRelationChanged(Relation.FAMILY) + }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.friend_profile_editor_relation_family), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + NearSmallRadioButton( + selected = friendProfileEditorUIState.relation == Relation.ACQUAINTANCE, + onClick = { onRelationChanged(Relation.ACQUAINTANCE) }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.friend_profile_editor_relation_acquaintance), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + } } - Spacer(modifier = Modifier.height(32.dp)) - Row( + } + Spacer(modifier = Modifier.height(33.dp)) + Row( + modifier = + Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.friend_profile_editor_contact_period), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(35.dp)) + Surface( modifier = - Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, + modifier + .weight(1f) + .onNoRippleClick({ + showBottomSheet.value = true + }), + shape = RoundedCornerShape(12.dp), + border = + BorderStroke( + width = 1.dp, + color = NearTheme.colors.GRAY03_EBEBEB, + ), + color = NearTheme.colors.WHITE_FFFFFF, ) { - Text( - "관계", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(72.dp)) Row( modifier = - Modifier - .weight(1f) - .padding(end = 35.dp), + Modifier.padding( + start = 16.dp, + end = 12.dp, + top = 14.dp, + bottom = 14.dp, + ), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - NearSmallRadioButton( - selected = friendProfileEditorUIState.relation == Relation.FRIEND, - onClick = { - onRelationChanged(Relation.FRIEND) - }, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.friend_profile_editor_relation_freind), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - NearSmallRadioButton( - selected = friendProfileEditorUIState.relation == Relation.FAMILY, - onClick = { - onRelationChanged(Relation.FAMILY) - }, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.friend_profile_editor_relation_family), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - } - Row(verticalAlignment = Alignment.CenterVertically) { - NearSmallRadioButton( - selected = friendProfileEditorUIState.relation == Relation.ACQUAINTANCE, - onClick = { onRelationChanged(Relation.ACQUAINTANCE) }, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.friend_profile_editor_relation_acquaintance), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - } + Text( + text = + stringResource(friendProfileEditorUIState.contactFrequency.reminderInterval.labelRes) + + stringResource( + R.string.friend_profile_editor_contact_period_format, + friendProfileEditorUIState.contactFrequency.dayOfWeek, + ), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + Image( + painter = painterResource(id = R.drawable.ic_24_down), + contentDescription = null, + ) } } - Spacer(modifier = Modifier.height(33.dp)) - Row( + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = + Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + val birthdayDatePickerState = remember { mutableStateOf(false) } + val datePickerState = + rememberDatePickerState() + if (birthdayDatePickerState.value) { + NearDatePicker( + datePickerState = datePickerState, + onDismiss = { birthdayDatePickerState.value = false }, + onDateSelected = { + it?.let { + onBirthdayChanged(it) + } + }, + ) + } + Text( + stringResource(R.string.friend_profile_editor_birthday), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(62.dp)) + Surface( modifier = - Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, + modifier + .weight(1f), + shape = RoundedCornerShape(12.dp), + border = + BorderStroke( + width = 1.dp, + color = NearTheme.colors.GRAY03_EBEBEB, + ), + color = NearTheme.colors.WHITE_FFFFFF, ) { - Text( - "연락 주기", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(35.dp)) - Surface( + Row( modifier = - modifier - .weight(1f) - .onNoRippleClick({ - showBottomSheet.value = true - }), - shape = RoundedCornerShape(12.dp), - border = - BorderStroke( - width = 1.dp, - color = NearTheme.colors.GRAY03_EBEBEB, - ), - color = NearTheme.colors.WHITE_FFFFFF, - ) { - Row( - modifier = - Modifier.padding( + Modifier + .padding( start = 16.dp, end = 12.dp, top = 14.dp, bottom = 14.dp, - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = - stringResource(friendProfileEditorUIState.contactFrequency.reminderInterval.labelRes) + - "(${friendProfileEditorUIState.contactFrequency.dayOfWeek} 마다)", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - Image( - painter = painterResource(id = R.drawable.ic_24_down), - contentDescription = null, - ) - } - } - } - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = - Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - val birthdayDatePickerState = remember { mutableStateOf(false) } - val datePickerState = - rememberDatePickerState() - if (birthdayDatePickerState.value) { - NearDatePicker( - datePickerState = datePickerState, - onDismiss = { birthdayDatePickerState.value = false }, - onDateSelected = { - it?.let { - onBirthdayChanged(it) - } - }, - ) - } - Text( - "생일", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(62.dp)) - Surface( - modifier = - modifier - .weight(1f), - shape = RoundedCornerShape(12.dp), - border = - BorderStroke( - width = 1.dp, - color = NearTheme.colors.GRAY03_EBEBEB, - ), - color = NearTheme.colors.WHITE_FFFFFF, + ) + .onNoRippleClick({ + birthdayDatePickerState.value = true + }), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - Row( - modifier = - Modifier - .padding( - start = 16.dp, - end = 12.dp, - top = 14.dp, - bottom = 14.dp, - ).onNoRippleClick({ - birthdayDatePickerState.value = true - }), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - friendProfileEditorUIState.birthday.value ?: "날짜 선택", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - Image( - painter = painterResource(id = R.drawable.ic_24_down), - contentDescription = null, - ) - } + Text( + friendProfileEditorUIState.birthday.value ?: stringResource(R.string.friend_profile_editor_select_date), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + Image( + painter = painterResource(id = R.drawable.ic_24_down), + contentDescription = null, + ) } } - Spacer(modifier = Modifier.height(32.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "기념일", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Text( - modifier = - Modifier.onNoRippleClick(onClick = { - onAddAnniversary() - }), - text = "추가하기", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLUE01_5AA2E9, - ) - } - Spacer(modifier = Modifier.height(16.dp)) } + Spacer(modifier = Modifier.height(32.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.friend_profile_editor_anniversary), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Text( + modifier = + Modifier.onNoRippleClick(onClick = { + onAddAnniversary() + }), + text = stringResource(R.string.friend_profile_editor_anniversary_add), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLUE01_5AA2E9, + ) + } + Spacer(modifier = Modifier.height(16.dp)) } + } + if (friendProfileEditorUIState.anniversaries.isNotEmpty()) { items( count = friendProfileEditorUIState.anniversaries.size, ) { index -> @@ -465,7 +469,7 @@ fun FriendProfileEditorScreen( } Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = "기념일 이름", + text = stringResource(R.string.friend_profile_editor_anniversary_name), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.GRAY01_888888, ) @@ -481,7 +485,7 @@ fun FriendProfileEditorScreen( if (friendProfileEditorUIState.anniversaries[index].title.error) { Spacer(modifier = Modifier.height(8.dp)) Text( - "이름을 입력해주세요.", + stringResource(R.string.friend_profile_editor_name_hint_text), style = NearTheme.typography.FC_12_MEDIUM, color = NearTheme.colors.NEGATIVE_F04E4E, ) @@ -549,7 +553,7 @@ fun FriendProfileEditorScreen( }, ), textAlign = TextAlign.End, - text = "삭제하기", + text = stringResource(R.string.friend_profile_editor_delete), textDecoration = TextDecoration.Underline, color = NearTheme.colors.GRAY01_888888, ) @@ -581,13 +585,13 @@ fun FriendProfileEditorScreen( onMemoChanged(it) }, placeHolderText = - "꼭 기억해야 할 내용을 기록해보세요.\n" + + stringResource(R.string.friend_profile_editor_memo_default_text) + "예) 날생선 X, 작년 생일에\n" + "키링 선물함 등", maxTextCount = 100, ) + Spacer(modifier = Modifier.height(80.dp)) } - Spacer(modifier = Modifier.height(80.dp)) } } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt index 51b7d801..8892075e 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt @@ -177,7 +177,7 @@ class FriendProfileEditorViewModel fun onSubmit() { val updatedFriend = _uiState.value - if ((updatedFriend.name.error || updatedFriend.anniversaries.any { it.title.error })) { + if ((updatedFriend.name.error || updatedFriend.anniversaries.any { it.title.error || it.title.value.isBlank() })) { // error return } diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml index d7e46a5b..2b9aa4a9 100644 --- a/Near/app/src/main/res/values/strings.xml +++ b/Near/app/src/main/res/values/strings.xml @@ -36,7 +36,8 @@ 기념일 메모 꼭 기억해야 할 내용을 기록해보세요.\n예) 날생선 X, 작년 생일에 키링 선물함 등 - 친구 + %1$s 더 가까워졌어요 + 친구 가족 지인 @@ -50,5 +51,20 @@ 친구 가족 지인 - %1$s 더 가까워졌어요 + 연락 주기 + 생일 + 날짜 선택 + 삭제하기 + 꼭 기억해야 할 내용을 기록해보세요.\n + 이름을 입력해주세요. + 기념일 이름 + 추가하기 + 기념일 + (%1$s 마다) + 더 가까워졌어요! + 수정 + 삭제 + 이번달은 챙길 사람이 없네요. + %1$d번째 챙김 + From 6e07d8ec8f38e05af4fb74e39d2f68714d82ce00 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sat, 6 Sep 2025 14:35:38 +0900 Subject: [PATCH 092/100] =?UTF-8?q?fix:=20delete=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/alarmy/near/network/service/FriendService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 a07f12de..6aafb65d 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/service/FriendService.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/service/FriendService.kt @@ -1,6 +1,5 @@ package com.alarmy.near.network.service -import androidx.room.Delete import com.alarmy.near.network.request.FriendRequest import com.alarmy.near.network.response.CommonMessageEntity import com.alarmy.near.network.response.FriendEntity @@ -8,6 +7,7 @@ import com.alarmy.near.network.response.FriendRecordEntity import com.alarmy.near.network.response.FriendSummaryEntity import com.alarmy.near.network.response.MonthlyFriendEntity import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.PUT @@ -31,7 +31,7 @@ interface FriendService { @Body friendRequest: FriendRequest, ): FriendEntity - @Delete + @DELETE("/friend/{friendId}") suspend fun deleteFriend( @Path("friendId") friendId: String, ) From 66c1ddd60460beae97f340708aef65864e81d0fe Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 7 Sep 2025 15:22:01 +0900 Subject: [PATCH 093/100] =?UTF-8?q?refactor:=20string=20res=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../friendprofile/FriendProfileScreen.kt | 2 +- .../FriendProfileEditorScreen.kt | 23 +++++++------- .../dialog/EditorExitDialog.kt | 11 +++---- Near/app/src/main/res/values/strings.xml | 30 ++++++++++++++----- 4 files changed, 41 insertions(+), 25 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index 16adc03f..a9659236 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -474,7 +474,7 @@ private fun RecordTab( Column(modifier = modifier.padding(horizontal = 24.dp)) { Spacer(modifier = Modifier.height(24.dp)) Text( - "챙김 기록", + stringResource(R.string.friend_profile_info_record_title_text), style = NearTheme.typography.B2_14_BOLD, color = NearTheme.colors.BLACK_1A1A1A, ) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index f7a03878..33cb7dc8 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -66,6 +67,8 @@ fun FriendProfileEditorRoute( ) { val friendProfileEditorUIState = viewModel.uiState.collectAsStateWithLifecycle() val warningDialogState = remember { mutableStateOf(false) } + val context = LocalContext.current + LaunchedEffect(viewModel.uiEvent) { launch { viewModel.uiEvent.collect { event -> @@ -84,7 +87,7 @@ fun FriendProfileEditorRoute( } FriendProfileEditorUIEvent.FriendProfileEditNetworkError -> { - onShowErrorSnackBar(IllegalStateException("네트워크 에러가 발생했습니다.")) + onShowErrorSnackBar(IllegalStateException(context.getString(R.string.network_error_message))) } is FriendProfileEditorUIEvent.FriendProfileEditSuccess -> { @@ -172,7 +175,7 @@ fun FriendProfileEditorScreen( Modifier.onNoRippleClick(onClick = { onSubmit() }), - text = "완료", + text = context.getString(R.string.friend_profile_editor_edit_complete_text), style = NearTheme.typography.B1_16_BOLD, color = NearTheme.colors.BLACK_1A1A1A, ) @@ -194,7 +197,7 @@ fun FriendProfileEditorScreen( modifier = Modifier.padding(top = 16.dp), text = buildAnnotatedString { - append("이름") + append(stringResource(R.string.friend_profile_editor_name)) withStyle( style = SpanStyle( @@ -220,7 +223,7 @@ fun FriendProfileEditorScreen( if (friendProfileEditorUIState.name.error) { Spacer(modifier = Modifier.height(8.dp)) Text( - "이름을 입력해주세요.", + stringResource(R.string.friend_profile_editor_enter_name), style = NearTheme.typography.FC_12_MEDIUM, color = NearTheme.colors.NEGATIVE_F04E4E, ) @@ -233,7 +236,7 @@ fun FriendProfileEditorScreen( verticalAlignment = Alignment.CenterVertically, ) { Text( - "관계", + stringResource(R.string.friend_profile_editor_relation), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.GRAY01_888888, ) @@ -498,7 +501,7 @@ fun FriendProfileEditorScreen( verticalAlignment = Alignment.CenterVertically, ) { Text( - "날짜", + stringResource(R.string.friend_profile_editor_date), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.GRAY01_888888, ) @@ -531,7 +534,7 @@ fun FriendProfileEditorScreen( ) { Text( friendProfileEditorUIState.anniversaries[index].date.value - ?: "날짜 선택", + ?: stringResource(R.string.friend_profile_editor_select_date), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLACK_1A1A1A, ) @@ -570,7 +573,7 @@ fun FriendProfileEditorScreen( ) { Text( modifier = Modifier.padding(top = 16.dp), - text = "메모", + text = stringResource(R.string.friend_profile_editor_memo), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.GRAY01_888888, ) @@ -585,9 +588,7 @@ fun FriendProfileEditorScreen( onMemoChanged(it) }, placeHolderText = - stringResource(R.string.friend_profile_editor_memo_default_text) + - "예) 날생선 X, 작년 생일에\n" + - "키링 선물함 등", + stringResource(R.string.friend_profile_editor_memo_default_text), maxTextCount = 100, ) Spacer(modifier = Modifier.height(80.dp)) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/dialog/EditorExitDialog.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/dialog/EditorExitDialog.kt index 5aa9c093..df55ac6d 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/dialog/EditorExitDialog.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/dialog/EditorExitDialog.kt @@ -6,8 +6,10 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.alarmy.near.R import com.alarmy.near.presentation.ui.theme.NearTheme @Composable @@ -20,27 +22,26 @@ internal fun EditorExitDialog( modifier = modifier, onDismissRequest = onDismissRequest, title = { - Text(text = "수정을 그만두시나요?") + Text(text = stringResource(R.string.editor_exit_title)) }, text = { Text( text = - "화면을 나가면 \n" + - "수정 내용은 저장되지 않아요.", + stringResource(R.string.editor_exit_content), ) }, confirmButton = { TextButton( onClick = onConfirm, ) { - Text("확인") + Text(stringResource(R.string.editor_exit_confirm)) } }, dismissButton = { TextButton( onClick = onDismissRequest, ) { - Text("취소") + Text(stringResource(R.string.editor_exit_dismiss)) } }, shape = RoundedCornerShape(24.dp), diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml index 2b9aa4a9..ab74ee07 100644 --- a/Near/app/src/main/res/values/strings.xml +++ b/Near/app/src/main/res/values/strings.xml @@ -6,6 +6,7 @@ 뒤로 가기 메뉴 + 네트워크 에러가 발생했습니다. Near 로고 @@ -40,13 +41,6 @@ 친구 가족 지인 - - - 매일 - 매주 - 2주 - 매달 - 6개월 프로필 상세 친구 가족 @@ -55,7 +49,7 @@ 생일 날짜 선택 삭제하기 - 꼭 기억해야 할 내용을 기록해보세요.\n + 꼭 기억해야 할 내용을 기록해보세요.\n예) 날생선 X, 작년 생일에 키링 선물함 등 이름을 입력해주세요. 기념일 이름 추가하기 @@ -66,5 +60,25 @@ 삭제 이번달은 챙길 사람이 없네요. %1$d번째 챙김 + 챙김 기록 + 완료 + 이름 + 이름을 입력해주세요. + 관계 + 날짜 + 메모 + + + 매일 + 매주 + 2주 + 매달 + 6개월 + + + 수정을 그만두시나요? + 화면을 나가면 \n수정 내용은 저장되지 않아요. + 확인 + 취소 From adfd5f4e50a02b47b3b35d325c1874c925cb22d7 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 7 Sep 2025 15:26:47 +0900 Subject: [PATCH 094/100] =?UTF-8?q?refactor:=20=EC=97=B0=EB=9D=BD=20?= =?UTF-8?q?=EB=B9=88=EB=8F=84=20=EB=A7=A4=EC=A7=81=EB=84=98=EB=B2=84=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/alarmy/near/data/mapper/FriendSummaryMapper.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt index 07450b6a..c1811712 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt @@ -4,6 +4,10 @@ import com.alarmy.near.model.friendsummary.ContactFrequencyLevel import com.alarmy.near.model.friendsummary.FriendSummary import com.alarmy.near.network.response.FriendSummaryEntity +private val CONTACT_FREQUENCY_LOW_RANGE = 0..29 +private val CONTACT_FREQUENCY_MIDDLE_RANGE = 30..69 +private val CONTACT_FREQUENCY_HIGH_RANGE = 70..100 + fun FriendSummaryEntity.toModel(): FriendSummary = FriendSummary( id = friendId, @@ -13,9 +17,9 @@ fun FriendSummaryEntity.toModel(): FriendSummary = isContacted = true, contactFrequencyLevel = when (checkRate) { - in 0..29 -> ContactFrequencyLevel.LOW - in 30..69 -> ContactFrequencyLevel.MIDDLE - in 70..100 -> ContactFrequencyLevel.HIGH + in CONTACT_FREQUENCY_LOW_RANGE -> ContactFrequencyLevel.LOW + in CONTACT_FREQUENCY_MIDDLE_RANGE -> ContactFrequencyLevel.MIDDLE + in CONTACT_FREQUENCY_HIGH_RANGE -> ContactFrequencyLevel.HIGH else -> ContactFrequencyLevel.LOW }, ) From 258fa456e0939809a3a00b6a31186d317936dbe4 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 7 Sep 2025 15:27:36 +0900 Subject: [PATCH 095/100] =?UTF-8?q?fix:=20context=20->=20stringres=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../friendprofileedittor/FriendProfileEditorScreen.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index 33cb7dc8..2c49e475 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -175,7 +175,7 @@ fun FriendProfileEditorScreen( Modifier.onNoRippleClick(onClick = { onSubmit() }), - text = context.getString(R.string.friend_profile_editor_edit_complete_text), + text = stringResource(R.string.friend_profile_editor_edit_complete_text), style = NearTheme.typography.B1_16_BOLD, color = NearTheme.colors.BLACK_1A1A1A, ) @@ -397,8 +397,7 @@ fun FriendProfileEditorScreen( end = 12.dp, top = 14.dp, bottom = 14.dp, - ) - .onNoRippleClick({ + ).onNoRippleClick({ birthdayDatePickerState.value = true }), verticalAlignment = Alignment.CenterVertically, From 867511c6be9fc4002fce9731858931bafcfae080 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 7 Sep 2025 16:28:59 +0900 Subject: [PATCH 096/100] =?UTF-8?q?refactor:=20dayOfWeek=20enum=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alarmy/near/data/mapper/FriendMapper.kt | 5 ++-- .../main/java/com/alarmy/near/model/Friend.kt | 17 ++++++++++- .../near/network/response/FriendEntity.kt | 1 + .../friendprofile/FriendProfileScreen.kt | 3 +- .../FriendProfileEditorScreen.kt | 11 ++++--- .../uistate/FriendProfileEditorUIState.kt | 29 ++----------------- Near/app/src/main/res/values/strings.xml | 7 +++++ 7 files changed, 38 insertions(+), 35 deletions(-) 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 3e19c710..68203c95 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt @@ -2,6 +2,7 @@ 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 @@ -29,7 +30,7 @@ fun FriendEntity.toModel(): Friend = fun ContactFrequencyEntity.toModel(): ContactFrequency = ContactFrequency( reminderInterval = ReminderInterval.valueOf(contactWeek), - dayOfWeek = dayOfWeek, + dayOfWeek = DayOfWeek.valueOf(dayOfWeek), ) fun AnniversaryEntity.toModel(): Anniversary = @@ -53,7 +54,7 @@ fun Friend.toRequest(): FriendRequest = fun ContactFrequency.toRequest(): ContactFrequencyRequest = ContactFrequencyRequest( contactWeek = reminderInterval.toString(), - dayOfWeek = dayOfWeek, + dayOfWeek = dayOfWeek.toString(), ) fun Anniversary.toRequest(): AnniversaryRequest = diff --git a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt index 8cba165a..b37b23a6 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt @@ -1,6 +1,8 @@ package com.alarmy.near.model import android.os.Parcelable +import androidx.annotation.StringRes +import com.alarmy.near.R import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import java.time.LocalDate @@ -36,7 +38,7 @@ data class Friend( @Parcelize data class ContactFrequency( val reminderInterval: ReminderInterval, - val dayOfWeek: String, + val dayOfWeek: DayOfWeek, ) : Parcelable @Serializable @@ -46,3 +48,16 @@ data class Anniversary( val title: String, val date: String? = null, ) : Parcelable + +@Serializable +enum class DayOfWeek( + @param:StringRes val resId: Int, +) { + MONDAY(R.string.day_of_week_monday), + TUESDAY(R.string.day_of_week_tuesday), + WEDNESDAY(R.string.day_of_week_wednesday), + THURSDAY(R.string.day_of_week_thursday), + FRIDAY(R.string.day_of_week_friday), + SATURDAY(R.string.day_of_week_saturday), + SUNDAY(R.string.day_of_week_sunday), +} diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt b/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt index 16e38353..dcea2b5c 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt @@ -1,5 +1,6 @@ package com.alarmy.near.network.response +import com.alarmy.near.model.DayOfWeek import kotlinx.serialization.Serializable @Serializable diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index a9659236..481d6175 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -59,6 +59,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.DayOfWeek import com.alarmy.near.model.Friend import com.alarmy.near.model.FriendRecord import com.alarmy.near.model.Relation @@ -701,7 +702,7 @@ fun FriendProfileScreenPreview() { contactFrequency = ContactFrequency( reminderInterval = ReminderInterval.EVERY_TWO_WEEK, - dayOfWeek = "MONDAY", + dayOfWeek = DayOfWeek.THURSDAY, ), birthday = "1998-11-13", anniversaryList = listOf(), diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index 2c49e475..6a45bb50 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width @@ -42,6 +43,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.alarmy.near.R import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.DayOfWeek import com.alarmy.near.model.Friend import com.alarmy.near.model.Relation import com.alarmy.near.model.ReminderInterval @@ -138,6 +140,7 @@ fun FriendProfileEditorScreen( ) { val density = LocalDensity.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } + val navigationBarHeightDp = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } val showBottomSheet = remember { mutableStateOf(false) } if (showBottomSheet.value) { ReminderIntervalBottomSheet(onDismissRequest = { @@ -161,10 +164,10 @@ fun FriendProfileEditorScreen( modifier = Modifier .fillMaxSize() - .background(NearTheme.colors.WHITE_FFFFFF), + .background(NearTheme.colors.WHITE_FFFFFF) + .padding(top = statusBarHeightDp, bottom = navigationBarHeightDp), ) { item { - Spacer(modifier = Modifier.padding(top = statusBarHeightDp)) NearTopAppbar( modifier = Modifier.padding(end = 24.dp), title = "", @@ -338,7 +341,7 @@ fun FriendProfileEditorScreen( stringResource(friendProfileEditorUIState.contactFrequency.reminderInterval.labelRes) + stringResource( R.string.friend_profile_editor_contact_period_format, - friendProfileEditorUIState.contactFrequency.dayOfWeek, + stringResource(friendProfileEditorUIState.contactFrequency.dayOfWeek.resId), ), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLACK_1A1A1A, @@ -606,7 +609,7 @@ fun FriendProfileEditorScreenPreview() { contactFrequency = ContactFrequency( reminderInterval = ReminderInterval.EVERY_DAY, - dayOfWeek = "2025-01-01", + dayOfWeek = DayOfWeek.SATURDAY, ), ), ) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt index 3d2f2b04..4e92060a 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt @@ -29,20 +29,7 @@ fun Friend.toUiModel(): FriendProfileEditorUIState = FriendProfileEditorUIState( name = InputField(name), relation = relation, - contactFrequency = - contactFrequency.copy( - dayOfWeek = - when (contactFrequency.dayOfWeek) { - "MONDAY" -> "월요일" - "TUESDAY" -> "화요일" - "WEDNESDAY" -> "수요일" - "THURSDAY" -> "목요일" - "FRIDAY" -> "금요일" - "SATURDAY" -> "토요일" - "SUNDAY" -> "일요일" - else -> IllegalStateException("없는 타입 입니다.") - } as String, - ), + contactFrequency = contactFrequency, birthday = InputField(birthday), anniversaries = anniversaryList.map { it.toUiModel() }, memo = InputField(memo), @@ -64,19 +51,7 @@ fun FriendProfileEditorUIState.toModel( name = name.value, relation = relation, contactFrequency = - contactFrequency.copy( - dayOfWeek = - when (contactFrequency.dayOfWeek) { - "월요일" -> "MONDAY" - "화요일" -> "TUESDAY" - "수요일" -> "WEDNESDAY" - "목요일" -> "THURSDAY" - "금요일" -> "FRIDAY" - "토요일" -> "SATURDAY" - "일요일" -> "SUNDAY" - else -> IllegalStateException("없는 타입 입니다.") - } as String, - ), + contactFrequency, birthday = birthday.value?.replace(".", "-"), anniversaryList = anniversaries.map { diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml index ab74ee07..173ad5c1 100644 --- a/Near/app/src/main/res/values/strings.xml +++ b/Near/app/src/main/res/values/strings.xml @@ -80,5 +80,12 @@ 화면을 나가면 \n수정 내용은 저장되지 않아요. 확인 취소 + 월요일 + 화요일 + 수요일 + 목요일 + 금요일 + 토요일 + 일요일 From a77678db905751c62fba6309a312a64c73be60d6 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 7 Sep 2025 16:30:35 +0900 Subject: [PATCH 097/100] =?UTF-8?q?fix:=20=EB=84=A4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=EB=B0=94=20=ED=8C=A8=EB=94=A9=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/feature/friendprofile/FriendProfileScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index 481d6175..c56a31ff 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars @@ -142,13 +143,14 @@ fun FriendProfileScreen( ) { val density = LocalDensity.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } + val navigationBarHeightDp = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } val currentTabPosition = remember { mutableIntStateOf(0) } val dropdownState = remember { mutableStateOf(false) } Box( modifier = modifier .background(NearTheme.colors.WHITE_FFFFFF) - .padding(top = statusBarHeightDp, bottom = 24.dp), + .padding(top = statusBarHeightDp, bottom = navigationBarHeightDp + 24.dp), ) { when (friendState) { is FriendState.Success -> { From 72acb6aab4eefed4ed50d26efb194f8612cb8293 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Sun, 7 Sep 2025 16:34:20 +0900 Subject: [PATCH 098/100] =?UTF-8?q?fix:=20records=20isEmpty=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EB=A5=BC=20=ED=86=B5=ED=95=9C=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/feature/friendprofile/FriendProfileScreen.kt | 2 +- .../feature/friendprofile/FriendProfileViewModel.kt | 2 ++ .../feature/friendprofile/uistate/FriendShipRecordState.kt | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index c56a31ff..cd8140ca 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -481,7 +481,7 @@ private fun RecordTab( style = NearTheme.typography.B2_14_BOLD, color = NearTheme.colors.BLACK_1A1A1A, ) - if (friendShipRecordState.records.isEmpty()) { + if (friendShipRecordState.isEmpty) { Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { Spacer(modifier = Modifier.height(60.dp)) Image(painterResource(R.drawable.img_100_character_empty), contentDescription = null) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt index ec245001..141e4594 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt @@ -69,12 +69,14 @@ class FriendProfileViewModel _friendShipRecordStateFlow.update { it.copy( records = records.filter { record -> record.isChecked }, + isEmpty = records.isEmpty(), isLoading = false, ) } }.catch { error -> _friendShipRecordStateFlow.update { it.copy( + isEmpty = true, isLoading = false, ) } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendShipRecordState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendShipRecordState.kt index e5041ff5..8fa62e68 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendShipRecordState.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendShipRecordState.kt @@ -4,5 +4,6 @@ import com.alarmy.near.model.FriendRecord data class FriendShipRecordState( val records: List = emptyList(), + val isEmpty: Boolean = true, val isLoading: Boolean = false, ) From eb93e25019289be6b9c3d64d682019d5136fb973 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Mon, 8 Sep 2025 00:32:09 +0900 Subject: [PATCH 099/100] =?UTF-8?q?feat:=20NearFrame=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../near/network/auth/TestTokenInterceptor.kt | 21 + .../alarmy/near/network/di/NetworkModule.kt | 7 +- .../friendprofile/FriendProfileScreen.kt | 500 ++++++------ .../FriendProfileEditorScreen.kt | 717 +++++++++--------- .../presentation/ui/component/NearFrame.kt | 35 + 5 files changed, 665 insertions(+), 615 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/network/auth/TestTokenInterceptor.kt create mode 100644 Near/app/src/main/java/com/alarmy/near/presentation/ui/component/NearFrame.kt diff --git a/Near/app/src/main/java/com/alarmy/near/network/auth/TestTokenInterceptor.kt b/Near/app/src/main/java/com/alarmy/near/network/auth/TestTokenInterceptor.kt new file mode 100644 index 00000000..f1b24c8f --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/auth/TestTokenInterceptor.kt @@ -0,0 +1,21 @@ +package com.alarmy.near.network.auth + +import com.alarmy.near.BuildConfig +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject + +class TestTokenInterceptor + @Inject + constructor() : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + return chain.proceed( + request = + request + .newBuilder() + .addHeader("Authorization", "Bearer ${BuildConfig.TEMP_TOKEN}") + .build(), + ) + } + } diff --git a/Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt b/Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt index e6663ac9..2b3938d7 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt @@ -1,6 +1,7 @@ package com.alarmy.near.network.di import com.alarmy.near.BuildConfig +import com.alarmy.near.network.auth.TestTokenInterceptor import com.alarmy.near.network.auth.TokenAuthenticator import com.alarmy.near.network.auth.TokenInterceptor import dagger.Module @@ -34,12 +35,14 @@ object NetworkModule { loggingInterceptor: HttpLoggingInterceptor, tokenInterceptor: TokenInterceptor, tokenAuthenticator: TokenAuthenticator, + testTokenInterceptor: TestTokenInterceptor, ): OkHttpClient = OkHttpClient .Builder() .addInterceptor(loggingInterceptor) - .addInterceptor(tokenInterceptor) - .authenticator(tokenAuthenticator) + .addInterceptor(testTokenInterceptor) +// .addInterceptor(tokenInterceptor) +// .authenticator(tokenAuthenticator) .build() @Provides diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index cd8140ca..2113cb8f 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -70,6 +70,7 @@ import com.alarmy.near.presentation.feature.friendprofile.component.MessageButto import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendProfileUIEvent import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendShipRecordState import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendState +import com.alarmy.near.presentation.ui.component.NearFrame import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar import com.alarmy.near.presentation.ui.component.button.NearSolidTypeButton import com.alarmy.near.presentation.ui.extension.onNoRippleClick @@ -141,292 +142,287 @@ fun FriendProfileScreen( onDeleteFriend: (friendId: String) -> Unit = {}, onDismissRecordSuccessDialog: () -> Unit = {}, ) { - val density = LocalDensity.current - val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } - val navigationBarHeightDp = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } val currentTabPosition = remember { mutableIntStateOf(0) } val dropdownState = remember { mutableStateOf(false) } - Box( - modifier = - modifier - .background(NearTheme.colors.WHITE_FFFFFF) - .padding(top = statusBarHeightDp, bottom = navigationBarHeightDp + 24.dp), - ) { - when (friendState) { - is FriendState.Success -> { - val friend = friendState.friend - Column( - modifier = - Modifier - .align(Alignment.TopStart) - .fillMaxSize() - .background(NearTheme.colors.WHITE_FFFFFF), - ) { - if (recordSuccessDialogState) { - LaunchedEffect(true) { - if (recordSuccessDialogState) { - delay(2000L) - onDismissRecordSuccessDialog() + + NearFrame(modifier = modifier) { + Box { + when (friendState) { + is FriendState.Success -> { + val friend = friendState.friend + Column( + modifier = + Modifier + .align(Alignment.TopStart) + .fillMaxSize() + .background(NearTheme.colors.WHITE_FFFFFF), + ) { + if (recordSuccessDialogState) { + LaunchedEffect(true) { + if (recordSuccessDialogState) { + delay(2000L) + onDismissRecordSuccessDialog() + } + } + Dialog(onDismissRequest = onDismissRecordSuccessDialog) { + Column( + modifier = + Modifier + .width(255.dp) + .height(186.dp) + .background( + color = NearTheme.colors.WHITE_FFFFFF, + shape = RoundedCornerShape(16.dp), + ), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painterResource(R.drawable.img_100_character_success), + contentDescription = "", + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + stringResource(R.string.friend_profile_info_contact_success_text), + style = NearTheme.typography.B1_16_BOLD, + color = Color(0xff222222), + ) + } } } - Dialog(onDismissRequest = onDismissRecordSuccessDialog) { - Column( + NearTopAppbar( + title = stringResource(R.string.friend_profile_title), + onClickBackButton = onClickBackButton, + menuButton = { + Column(modifier = Modifier.padding(end = 20.dp)) { + Image( + modifier = + Modifier + .onNoRippleClick(onClick = { + dropdownState.value = true + }), + painter = painterResource(R.drawable.ic_32_menu), + contentDescription = stringResource(R.string.common_menu_button_description), + ) + DropdownMenu( + modifier = Modifier.background(color = NearTheme.colors.WHITE_FFFFFF), + expanded = dropdownState.value, + shape = RoundedCornerShape(12.dp), + onDismissRequest = { dropdownState.value = false }, + ) { + DropdownMenuItem( + onClick = { + onEditFriendInfo(friend) + dropdownState.value = false + }, + text = { + Text( + stringResource(R.string.friend_profile_info_edit), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + }, + ) + DropdownMenuItem( + onClick = { + onDeleteFriend(friend.friendId) + dropdownState.value = false + }, + text = { + Text( + stringResource(R.string.friend_profile_info_delete), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + }, + ) + } + } + }, + ) + + Spacer(modifier = Modifier.height(18.dp)) + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( modifier = - Modifier - .width(255.dp) - .height(186.dp) - .background( - color = NearTheme.colors.WHITE_FFFFFF, - shape = RoundedCornerShape(16.dp), - ), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, + Modifier, ) { Image( - painterResource(R.drawable.img_100_character_success), - contentDescription = "", + modifier = Modifier.align(Alignment.Center), + painter = painterResource(R.drawable.img_80_user1), + contentDescription = null, ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - stringResource(R.string.friend_profile_info_contact_success_text), - style = NearTheme.typography.B1_16_BOLD, - color = Color(0xff222222), - ) - } - } - } - NearTopAppbar( - title = stringResource(R.string.friend_profile_title), - onClickBackButton = onClickBackButton, - menuButton = { - Column(modifier = Modifier.padding(end = 20.dp)) { Image( modifier = Modifier - .onNoRippleClick(onClick = { - dropdownState.value = true - }), - painter = painterResource(R.drawable.ic_32_menu), - contentDescription = stringResource(R.string.common_menu_button_description), + .align(Alignment.TopEnd) + .offset(x = 2.dp, y = (-2).dp), + painter = painterResource(R.drawable.ic_visual_24_emoji_0), + contentDescription = null, ) - DropdownMenu( - modifier = Modifier.background(color = NearTheme.colors.WHITE_FFFFFF), - expanded = dropdownState.value, - shape = RoundedCornerShape(12.dp), - onDismissRequest = { dropdownState.value = false }, - ) { - DropdownMenuItem( - onClick = { - onEditFriendInfo(friend) - dropdownState.value = false - }, - text = { - Text( - stringResource(R.string.friend_profile_info_edit), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - }, - ) - DropdownMenuItem( - onClick = { - onDeleteFriend(friend.friendId) - dropdownState.value = false - }, - text = { - Text( - stringResource(R.string.friend_profile_info_delete), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - }, + } + Spacer(modifier = Modifier.width(24.dp)) + Column { + Text( + modifier = Modifier.widthIn(max = 145.dp), + text = friend.name, + style = NearTheme.typography.B1_16_BOLD, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (friend.lastContactAt != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = + stringResource( + R.string.friend_profile_last_contact_date_format, + friend.lastContactAt.lastContactFormat(), + ), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLUE01_5AA2E9, ) } } - }, - ) - - Spacer(modifier = Modifier.height(18.dp)) - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( + } + Spacer(modifier = Modifier.height(24.dp)) + Row( modifier = - Modifier, + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), ) { - Image( - modifier = Modifier.align(Alignment.Center), - painter = painterResource(R.drawable.img_80_user1), - contentDescription = null, + CallButton( + modifier = Modifier.weight(1f), + enabled = !friend.phone.isNullOrBlank(), + onClick = { + friend.phone?.let { + onClickCallButton(friend.phone) + } + }, ) - Image( - modifier = - Modifier - .align(Alignment.TopEnd) - .offset(x = 2.dp, y = (-2).dp), - painter = painterResource(R.drawable.ic_visual_24_emoji_0), - contentDescription = null, + Spacer(modifier = Modifier.width(7.dp)) + MessageButton( + Modifier.weight(1f), + enabled = !friend.phone.isNullOrBlank(), + onClick = { + friend.phone?.let { + onClickMessageButton(friend.phone) + } + }, ) } - Spacer(modifier = Modifier.width(24.dp)) - Column { - Text( - modifier = Modifier.widthIn(max = 145.dp), - text = friend.name, - style = NearTheme.typography.B1_16_BOLD, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - if (friend.lastContactAt != null) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = - stringResource( - R.string.friend_profile_last_contact_date_format, - friend.lastContactAt.lastContactFormat(), - ), - style = NearTheme.typography.B2_14_MEDIUM, + Spacer(modifier = Modifier.height(24.dp)) + TabRow( + modifier = + Modifier + .padding(horizontal = 25.dp) + .width(170.dp), + containerColor = NearTheme.colors.WHITE_FFFFFF, + selectedTabIndex = 0, + divider = {}, + indicator = { + TabRowDefaults.SecondaryIndicator( + modifier = + Modifier + .customTabIndicatorOffset( + it[currentTabPosition.intValue], + 80.dp, + ), // 넓이, 애니메이션 지정 + // 모양 지정 + height = 3.dp, color = NearTheme.colors.BLUE01_5AA2E9, ) - } - } - } - Spacer(modifier = Modifier.height(24.dp)) - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - ) { - CallButton( - modifier = Modifier.weight(1f), - enabled = !friend.phone.isNullOrBlank(), - onClick = { - friend.phone?.let { - onClickCallButton(friend.phone) - } }, - ) - Spacer(modifier = Modifier.width(7.dp)) - MessageButton( - Modifier.weight(1f), - enabled = !friend.phone.isNullOrBlank(), - onClick = { - friend.phone?.let { - onClickMessageButton(friend.phone) + ) { + Tab( + modifier = + Modifier + .width(85.dp) + .height(50.dp), + selected = true, + onClick = { + currentTabPosition.intValue = 0 + }, + ) { + if (currentTabPosition.intValue == 0) { + Text( + text = stringResource(R.string.friend_profile_tab_text_profile), + style = NearTheme.typography.B2_14_BOLD, + color = NearTheme.colors.BLACK_1A1A1A, + ) + } else { + Text( + text = stringResource(R.string.friend_profile_tab_text_profile), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY02_B7B7B7, + ) } - }, - ) - } - Spacer(modifier = Modifier.height(24.dp)) - TabRow( - modifier = - Modifier - .padding(horizontal = 25.dp) - .width(170.dp), - containerColor = NearTheme.colors.WHITE_FFFFFF, - selectedTabIndex = 0, - divider = {}, - indicator = { - TabRowDefaults.SecondaryIndicator( + } + Tab( modifier = Modifier - .customTabIndicatorOffset( - it[currentTabPosition.intValue], - 80.dp, - ), // 넓이, 애니메이션 지정 - // 모양 지정 - height = 3.dp, - color = NearTheme.colors.BLUE01_5AA2E9, - ) - }, - ) { - Tab( - modifier = - Modifier - .width(85.dp) - .height(50.dp), - selected = true, - onClick = { - currentTabPosition.intValue = 0 - }, - ) { - if (currentTabPosition.intValue == 0) { - Text( - text = stringResource(R.string.friend_profile_tab_text_profile), - style = NearTheme.typography.B2_14_BOLD, - color = NearTheme.colors.BLACK_1A1A1A, - ) - } else { - Text( - text = stringResource(R.string.friend_profile_tab_text_profile), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY02_B7B7B7, - ) + .width(85.dp) + .height(50.dp), + selected = true, + onClick = { + currentTabPosition.intValue = 1 + }, + ) { + if (currentTabPosition.intValue == 1) { + Text( + text = stringResource(R.string.friend_profile_tab_text_record), + style = NearTheme.typography.B2_14_BOLD, + color = NearTheme.colors.BLACK_1A1A1A, + ) + } else { + Text( + text = stringResource(R.string.friend_profile_tab_text_record), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY02_B7B7B7, + ) + } } } - Tab( - modifier = - Modifier - .width(85.dp) - .height(50.dp), - selected = true, - onClick = { - currentTabPosition.intValue = 1 - }, - ) { - if (currentTabPosition.intValue == 1) { - Text( - text = stringResource(R.string.friend_profile_tab_text_record), - style = NearTheme.typography.B2_14_BOLD, - color = NearTheme.colors.BLACK_1A1A1A, - ) - } else { - Text( - text = stringResource(R.string.friend_profile_tab_text_record), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY02_B7B7B7, - ) - } + HorizontalDivider(thickness = 1.dp, color = NearTheme.colors.GRAY03_EBEBEB) + if (currentTabPosition.intValue == 0) { + ProfileTab(friend = friend) + } else { + RecordTab(friendShipRecordState = friendShipRecordState) } + Spacer(modifier = Modifier.height(60.dp)) } - HorizontalDivider(thickness = 1.dp, color = NearTheme.colors.GRAY03_EBEBEB) - if (currentTabPosition.intValue == 0) { - ProfileTab(friend = friend) - } else { - RecordTab(friendShipRecordState = friendShipRecordState) - } - Spacer(modifier = Modifier.height(60.dp)) + NearSolidTypeButton( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .align(Alignment.BottomCenter), + contentPadding = PaddingValues(vertical = 17.dp), + enabled = friend.isContactedToday.not(), + onClick = { onRecordFriendShip(friend.friendId) }, + text = stringResource(R.string.friend_profile_record_button_text), + ) } - NearSolidTypeButton( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - .align(Alignment.BottomCenter), - contentPadding = PaddingValues(vertical = 17.dp), - enabled = friend.isContactedToday.not(), - onClick = { onRecordFriendShip(friend.friendId) }, - text = stringResource(R.string.friend_profile_record_button_text), - ) - } - is FriendState.Loading -> { - Box(modifier = Modifier.fillMaxSize()) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + is FriendState.Loading -> { + Box(modifier = Modifier.fillMaxSize()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } } - } - is FriendState.Error -> { - Box(modifier = Modifier.fillMaxSize()) { - Text( - modifier = Modifier.align(Alignment.Center), - text = "프로필 정보를 불러오는데 실패했습니다.", - ) + is FriendState.Error -> { + Box(modifier = Modifier.fillMaxSize()) { + Text( + modifier = Modifier.align(Alignment.Center), + text = "프로필 정보를 불러오는데 실패했습니다.", + ) + } } } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index 6a45bb50..cdcf3714 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -52,6 +52,7 @@ import com.alarmy.near.presentation.feature.friendprofileedittor.component.Remin import com.alarmy.near.presentation.feature.friendprofileedittor.dialog.EditorExitDialog import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIEvent import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIState +import com.alarmy.near.presentation.ui.component.NearFrame import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar import com.alarmy.near.presentation.ui.component.radiobutton.NearSmallRadioButton import com.alarmy.near.presentation.ui.component.textfield.NearLimitedTextField @@ -138,9 +139,6 @@ fun FriendProfileEditorScreen( onEditorExit: () -> Unit = {}, onCloseDialog: () -> Unit = {}, ) { - val density = LocalDensity.current - val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } - val navigationBarHeightDp = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } val showBottomSheet = remember { mutableStateOf(false) } if (showBottomSheet.value) { ReminderIntervalBottomSheet(onDismissRequest = { @@ -160,342 +158,73 @@ fun FriendProfileEditorScreen( }, ) } - LazyColumn( - modifier = - Modifier - .fillMaxSize() - .background(NearTheme.colors.WHITE_FFFFFF) - .padding(top = statusBarHeightDp, bottom = navigationBarHeightDp), - ) { - item { - NearTopAppbar( - modifier = Modifier.padding(end = 24.dp), - title = "", - onClickBackButton = onClickBackButton, - menuButton = { - Text( - modifier = - Modifier.onNoRippleClick(onClick = { - onSubmit() - }), - text = stringResource(R.string.friend_profile_editor_edit_complete_text), - style = NearTheme.typography.B1_16_BOLD, - color = NearTheme.colors.BLACK_1A1A1A, - ) - }, - ) - Spacer(modifier = Modifier.height(16.dp)) - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(start = 24.dp, end = 20.dp), - ) { - Row( - modifier = - Modifier - .fillMaxWidth(), - ) { - Text( - modifier = Modifier.padding(top = 16.dp), - text = - buildAnnotatedString { - append(stringResource(R.string.friend_profile_editor_name)) - withStyle( - style = - SpanStyle( - color = NearTheme.colors.BLUE01_5AA2E9, - ), - ) { - append("*") - } - }, - textAlign = TextAlign.Center, - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(55.dp)) - NearTextField( - modifier = Modifier.weight(1f), - value = friendProfileEditorUIState.name.value, - onValueChange = { - onNameChanged(it) - }, - ) - } - if (friendProfileEditorUIState.name.error) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - stringResource(R.string.friend_profile_editor_enter_name), - style = NearTheme.typography.FC_12_MEDIUM, - color = NearTheme.colors.NEGATIVE_F04E4E, - ) - } - Spacer(modifier = Modifier.height(32.dp)) - Row( - modifier = - Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - stringResource(R.string.friend_profile_editor_relation), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(72.dp)) - Row( - modifier = - Modifier - .weight(1f) - .padding(end = 35.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - NearSmallRadioButton( - selected = friendProfileEditorUIState.relation == Relation.FRIEND, - onClick = { - onRelationChanged(Relation.FRIEND) - }, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.friend_profile_editor_relation_friend), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - NearSmallRadioButton( - selected = friendProfileEditorUIState.relation == Relation.FAMILY, - onClick = { - onRelationChanged(Relation.FAMILY) - }, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.friend_profile_editor_relation_family), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - } - Row(verticalAlignment = Alignment.CenterVertically) { - NearSmallRadioButton( - selected = friendProfileEditorUIState.relation == Relation.ACQUAINTANCE, - onClick = { onRelationChanged(Relation.ACQUAINTANCE) }, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.friend_profile_editor_relation_acquaintance), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - } - } - } - Spacer(modifier = Modifier.height(33.dp)) - Row( - modifier = - Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - stringResource(R.string.friend_profile_editor_contact_period), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(35.dp)) - Surface( - modifier = - modifier - .weight(1f) - .onNoRippleClick({ - showBottomSheet.value = true - }), - shape = RoundedCornerShape(12.dp), - border = - BorderStroke( - width = 1.dp, - color = NearTheme.colors.GRAY03_EBEBEB, - ), - color = NearTheme.colors.WHITE_FFFFFF, - ) { - Row( + NearFrame(modifier = modifier) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + NearTopAppbar( + modifier = Modifier.padding(end = 24.dp), + title = "", + onClickBackButton = onClickBackButton, + menuButton = { + Text( modifier = - Modifier.padding( - start = 16.dp, - end = 12.dp, - top = 14.dp, - bottom = 14.dp, - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = - stringResource(friendProfileEditorUIState.contactFrequency.reminderInterval.labelRes) + - stringResource( - R.string.friend_profile_editor_contact_period_format, - stringResource(friendProfileEditorUIState.contactFrequency.dayOfWeek.resId), - ), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - Image( - painter = painterResource(id = R.drawable.ic_24_down), - contentDescription = null, - ) - } - } - } - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = - Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - val birthdayDatePickerState = remember { mutableStateOf(false) } - val datePickerState = - rememberDatePickerState() - if (birthdayDatePickerState.value) { - NearDatePicker( - datePickerState = datePickerState, - onDismiss = { birthdayDatePickerState.value = false }, - onDateSelected = { - it?.let { - onBirthdayChanged(it) - } - }, + Modifier.onNoRippleClick(onClick = { + onSubmit() + }), + text = stringResource(R.string.friend_profile_editor_edit_complete_text), + style = NearTheme.typography.B1_16_BOLD, + color = NearTheme.colors.BLACK_1A1A1A, ) - } - Text( - stringResource(R.string.friend_profile_editor_birthday), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(62.dp)) - Surface( - modifier = - modifier - .weight(1f), - shape = RoundedCornerShape(12.dp), - border = - BorderStroke( - width = 1.dp, - color = NearTheme.colors.GRAY03_EBEBEB, - ), - color = NearTheme.colors.WHITE_FFFFFF, - ) { - Row( - modifier = - Modifier - .padding( - start = 16.dp, - end = 12.dp, - top = 14.dp, - bottom = 14.dp, - ).onNoRippleClick({ - birthdayDatePickerState.value = true - }), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - friendProfileEditorUIState.birthday.value ?: stringResource(R.string.friend_profile_editor_select_date), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - Image( - painter = painterResource(id = R.drawable.ic_24_down), - contentDescription = null, - ) - } - } - } - Spacer(modifier = Modifier.height(32.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource(R.string.friend_profile_editor_anniversary), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Text( - modifier = - Modifier.onNoRippleClick(onClick = { - onAddAnniversary() - }), - text = stringResource(R.string.friend_profile_editor_anniversary_add), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLUE01_5AA2E9, - ) - } + }, + ) Spacer(modifier = Modifier.height(16.dp)) - } - } - if (friendProfileEditorUIState.anniversaries.isNotEmpty()) { - items( - count = friendProfileEditorUIState.anniversaries.size, - ) { index -> Column( modifier = Modifier - .background(color = NearTheme.colors.BG02_F4F9FD) - .padding( - PaddingValues( - top = 20.dp, - bottom = 32.dp, - start = 24.dp, - end = 20.dp, - ), - ), + .fillMaxWidth() + .padding(start = 24.dp, end = 20.dp), ) { - val anniversaryDatePickerState = remember { mutableStateOf(false) } - val datePickerState = - rememberDatePickerState() - if (anniversaryDatePickerState.value) { - NearDatePicker( - datePickerState = datePickerState, - onDismiss = { anniversaryDatePickerState.value = false }, - onDateSelected = { - it?.let { - onAnniversaryDateSelected(index, it) - } - }, - ) - } - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = + Modifier + .fillMaxWidth(), + ) { Text( - text = stringResource(R.string.friend_profile_editor_anniversary_name), + modifier = Modifier.padding(top = 16.dp), + text = + buildAnnotatedString { + append(stringResource(R.string.friend_profile_editor_name)) + withStyle( + style = + SpanStyle( + color = NearTheme.colors.BLUE01_5AA2E9, + ), + ) { + append("*") + } + }, + textAlign = TextAlign.Center, style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.GRAY01_888888, ) - Spacer(modifier = Modifier.width(23.dp)) + Spacer(modifier = Modifier.width(55.dp)) NearTextField( modifier = Modifier.weight(1f), - value = friendProfileEditorUIState.anniversaries[index].title.value, + value = friendProfileEditorUIState.name.value, onValueChange = { - onAnniversaryNameChange(index, it) + onNameChanged(it) }, ) } - if (friendProfileEditorUIState.anniversaries[index].title.error) { + if (friendProfileEditorUIState.name.error) { Spacer(modifier = Modifier.height(8.dp)) Text( - stringResource(R.string.friend_profile_editor_name_hint_text), + stringResource(R.string.friend_profile_editor_enter_name), style = NearTheme.typography.FC_12_MEDIUM, color = NearTheme.colors.NEGATIVE_F04E4E, ) } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(32.dp)) Row( modifier = Modifier @@ -503,17 +232,83 @@ fun FriendProfileEditorScreen( verticalAlignment = Alignment.CenterVertically, ) { Text( - stringResource(R.string.friend_profile_editor_date), + stringResource(R.string.friend_profile_editor_relation), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.GRAY01_888888, ) - Spacer(modifier = Modifier.width(62.dp)) + Spacer(modifier = Modifier.width(72.dp)) + Row( + modifier = + Modifier + .weight(1f) + .padding(end = 35.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + NearSmallRadioButton( + selected = friendProfileEditorUIState.relation == Relation.FRIEND, + onClick = { + onRelationChanged(Relation.FRIEND) + }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.friend_profile_editor_relation_friend), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + NearSmallRadioButton( + selected = friendProfileEditorUIState.relation == Relation.FAMILY, + onClick = { + onRelationChanged(Relation.FAMILY) + }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.friend_profile_editor_relation_family), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + NearSmallRadioButton( + selected = friendProfileEditorUIState.relation == Relation.ACQUAINTANCE, + onClick = { onRelationChanged(Relation.ACQUAINTANCE) }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.friend_profile_editor_relation_acquaintance), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + } + } + } + Spacer(modifier = Modifier.height(33.dp)) + Row( + modifier = + Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.friend_profile_editor_contact_period), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(35.dp)) Surface( modifier = - modifier + Modifier .weight(1f) - .onNoRippleClick(onClick = { - anniversaryDatePickerState.value = true + .onNoRippleClick({ + showBottomSheet.value = true }), shape = RoundedCornerShape(12.dp), border = @@ -535,7 +330,77 @@ fun FriendProfileEditorScreen( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - friendProfileEditorUIState.anniversaries[index].date.value + text = + stringResource(friendProfileEditorUIState.contactFrequency.reminderInterval.labelRes) + + stringResource( + R.string.friend_profile_editor_contact_period_format, + stringResource(friendProfileEditorUIState.contactFrequency.dayOfWeek.resId), + ), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + Image( + painter = painterResource(id = R.drawable.ic_24_down), + contentDescription = null, + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = + Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + val birthdayDatePickerState = remember { mutableStateOf(false) } + val datePickerState = + rememberDatePickerState() + if (birthdayDatePickerState.value) { + NearDatePicker( + datePickerState = datePickerState, + onDismiss = { birthdayDatePickerState.value = false }, + onDateSelected = { + it?.let { + onBirthdayChanged(it) + } + }, + ) + } + Text( + stringResource(R.string.friend_profile_editor_birthday), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(62.dp)) + Surface( + modifier = + Modifier + .weight(1f), + shape = RoundedCornerShape(12.dp), + border = + BorderStroke( + width = 1.dp, + color = NearTheme.colors.GRAY03_EBEBEB, + ), + color = NearTheme.colors.WHITE_FFFFFF, + ) { + Row( + modifier = + Modifier + .padding( + start = 16.dp, + end = 12.dp, + top = 14.dp, + bottom = 14.dp, + ).onNoRippleClick({ + birthdayDatePickerState.value = true + }), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + friendProfileEditorUIState.birthday.value ?: stringResource(R.string.friend_profile_editor_select_date), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLACK_1A1A1A, @@ -548,52 +413,182 @@ fun FriendProfileEditorScreen( } } Spacer(modifier = Modifier.height(32.dp)) - Text( + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.friend_profile_editor_anniversary), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Text( + modifier = + Modifier.onNoRippleClick(onClick = { + onAddAnniversary() + }), + text = stringResource(R.string.friend_profile_editor_anniversary_add), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLUE01_5AA2E9, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + } + if (friendProfileEditorUIState.anniversaries.isNotEmpty()) { + items( + count = friendProfileEditorUIState.anniversaries.size, + ) { index -> + Column( modifier = Modifier - .fillMaxWidth() - .onNoRippleClick( - onClick = { - onRemoveAnniversary(index) - }, + .background(color = NearTheme.colors.BG02_F4F9FD) + .padding( + PaddingValues( + top = 20.dp, + bottom = 32.dp, + start = 24.dp, + end = 20.dp, + ), ), - textAlign = TextAlign.End, - text = stringResource(R.string.friend_profile_editor_delete), - textDecoration = TextDecoration.Underline, - color = NearTheme.colors.GRAY01_888888, - ) + ) { + val anniversaryDatePickerState = remember { mutableStateOf(false) } + val datePickerState = + rememberDatePickerState() + if (anniversaryDatePickerState.value) { + NearDatePicker( + datePickerState = datePickerState, + onDismiss = { anniversaryDatePickerState.value = false }, + onDateSelected = { + it?.let { + onAnniversaryDateSelected(index, it) + } + }, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.friend_profile_editor_anniversary_name), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(23.dp)) + NearTextField( + modifier = Modifier.weight(1f), + value = friendProfileEditorUIState.anniversaries[index].title.value, + onValueChange = { + onAnniversaryNameChange(index, it) + }, + ) + } + if (friendProfileEditorUIState.anniversaries[index].title.error) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + stringResource(R.string.friend_profile_editor_name_hint_text), + style = NearTheme.typography.FC_12_MEDIUM, + color = NearTheme.colors.NEGATIVE_F04E4E, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = + Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.friend_profile_editor_date), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(62.dp)) + Surface( + modifier = + Modifier + .weight(1f) + .onNoRippleClick(onClick = { + anniversaryDatePickerState.value = true + }), + shape = RoundedCornerShape(12.dp), + border = + BorderStroke( + width = 1.dp, + color = NearTheme.colors.GRAY03_EBEBEB, + ), + color = NearTheme.colors.WHITE_FFFFFF, + ) { + Row( + modifier = + Modifier.padding( + start = 16.dp, + end = 12.dp, + top = 14.dp, + bottom = 14.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + friendProfileEditorUIState.anniversaries[index].date.value + ?: stringResource(R.string.friend_profile_editor_select_date), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + Image( + painter = painterResource(id = R.drawable.ic_24_down), + contentDescription = null, + ) + } + } + } + Spacer(modifier = Modifier.height(32.dp)) + Text( + modifier = + Modifier + .fillMaxWidth() + .onNoRippleClick( + onClick = { + onRemoveAnniversary(index) + }, + ), + textAlign = TextAlign.End, + text = stringResource(R.string.friend_profile_editor_delete), + textDecoration = TextDecoration.Underline, + color = NearTheme.colors.GRAY01_888888, + ) + } } } - } - item { - Spacer(modifier = Modifier.height(24.dp)) - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), - ) { - Text( - modifier = Modifier.padding(top = 16.dp), - text = stringResource(R.string.friend_profile_editor_memo), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - Spacer(modifier = Modifier.width(23.dp)) - NearLimitedTextField( + item { + Spacer(modifier = Modifier.height(24.dp)) + Row( modifier = Modifier - .weight(1f) - .height(180.dp), - value = friendProfileEditorUIState.memo.value ?: "", - onValueChange = { - onMemoChanged(it) - }, - placeHolderText = - stringResource(R.string.friend_profile_editor_memo_default_text), - maxTextCount = 100, - ) - Spacer(modifier = Modifier.height(80.dp)) + .fillMaxWidth() + .padding(horizontal = 24.dp), + ) { + Text( + modifier = Modifier.padding(top = 16.dp), + text = stringResource(R.string.friend_profile_editor_memo), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.width(23.dp)) + NearLimitedTextField( + modifier = + Modifier + .weight(1f) + .height(180.dp), + value = friendProfileEditorUIState.memo.value ?: "", + onValueChange = { + onMemoChanged(it) + }, + placeHolderText = + stringResource(R.string.friend_profile_editor_memo_default_text), + maxTextCount = 100, + ) + Spacer(modifier = Modifier.height(80.dp)) + } } } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/NearFrame.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/NearFrame.kt new file mode 100644 index 00000000..08821874 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/NearFrame.kt @@ -0,0 +1,35 @@ +package com.alarmy.near.presentation.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import com.alarmy.near.presentation.ui.theme.NearTheme + +@Composable +fun NearFrame( + modifier: Modifier = Modifier, + backgroundColor: Color = NearTheme.colors.WHITE_FFFFFF, + content: @Composable ColumnScope.() -> Unit, +) { + val density = LocalDensity.current + val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } + val navigationBarHeightDp = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } + Column( + modifier = + modifier + .fillMaxSize() + .background( + color = backgroundColor, + ).padding(top = statusBarHeightDp, bottom = navigationBarHeightDp), + content = content, + ) +} From 307da4808d391e7efb607e1d2fe2c84d7e25ada5 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Mon, 8 Sep 2025 00:39:53 +0900 Subject: [PATCH 100/100] =?UTF-8?q?feat:=20NearDropdown=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../friendprofile/FriendProfileScreen.kt | 26 ++---- .../presentation/feature/home/HomeScreen.kt | 18 ++-- .../ui/component/dropdown/NearDropDown.kt | 84 +++++++++++++++++++ 3 files changed, 96 insertions(+), 32 deletions(-) create mode 100644 Near/app/src/main/java/com/alarmy/near/presentation/ui/component/dropdown/NearDropDown.kt diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index 2113cb8f..b9376bb5 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -73,6 +73,8 @@ import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendState import com.alarmy.near.presentation.ui.component.NearFrame import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar import com.alarmy.near.presentation.ui.component.button.NearSolidTypeButton +import com.alarmy.near.presentation.ui.component.dropdown.NearDropdownMenu +import com.alarmy.near.presentation.ui.component.dropdown.NearDropdownMenuItem import com.alarmy.near.presentation.ui.extension.onNoRippleClick import com.alarmy.near.presentation.ui.theme.NearTheme import kotlinx.coroutines.delay @@ -204,37 +206,23 @@ fun FriendProfileScreen( painter = painterResource(R.drawable.ic_32_menu), contentDescription = stringResource(R.string.common_menu_button_description), ) - DropdownMenu( - modifier = Modifier.background(color = NearTheme.colors.WHITE_FFFFFF), + NearDropdownMenu( expanded = dropdownState.value, - shape = RoundedCornerShape(12.dp), onDismissRequest = { dropdownState.value = false }, ) { - DropdownMenuItem( + NearDropdownMenuItem( onClick = { onEditFriendInfo(friend) dropdownState.value = false }, - text = { - Text( - stringResource(R.string.friend_profile_info_edit), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - }, + text = stringResource(R.string.friend_profile_info_edit) ) - DropdownMenuItem( + NearDropdownMenuItem( onClick = { onDeleteFriend(friend.friendId) dropdownState.value = false }, - text = { - Text( - stringResource(R.string.friend_profile_info_delete), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - }, + text = stringResource(R.string.friend_profile_info_delete) ) } } 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 970753ea..0a146e16 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeScreen.kt @@ -22,8 +22,6 @@ import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -57,6 +55,8 @@ import com.alarmy.near.model.friendsummary.FriendSummary import com.alarmy.near.model.monthly.MonthlyFriend import com.alarmy.near.model.monthly.MonthlyFriendType import com.alarmy.near.presentation.feature.home.component.MyContacts +import com.alarmy.near.presentation.ui.component.dropdown.NearDropdownMenu +import com.alarmy.near.presentation.ui.component.dropdown.NearDropdownMenuItem import com.alarmy.near.presentation.ui.extension.dropShadow import com.alarmy.near.presentation.ui.extension.onNoRippleClick import com.alarmy.near.presentation.ui.theme.NearTheme @@ -311,24 +311,16 @@ internal fun HomeScreen( painter = painterResource(R.drawable.ic_32_menu), contentDescription = stringResource(R.string.home_my_people_setting), ) - DropdownMenu( - modifier = Modifier.background(color = NearTheme.colors.WHITE_FFFFFF), + NearDropdownMenu( expanded = dropdownState.value, - shape = RoundedCornerShape(12.dp), onDismissRequest = { dropdownState.value = false }, ) { - DropdownMenuItem( + NearDropdownMenuItem( onClick = { // TODO 연락처 화면 이동 dropdownState.value = false }, - text = { - Text( - stringResource(R.string.home_menu_text_add_friend), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A, - ) - }, + text = stringResource(R.string.home_menu_text_add_friend), ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/dropdown/NearDropDown.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/dropdown/NearDropDown.kt new file mode 100644 index 00000000..f92309b8 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/dropdown/NearDropDown.kt @@ -0,0 +1,84 @@ +package com.alarmy.near.presentation.ui.component.dropdown + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.alarmy.near.presentation.ui.theme.NearTheme + +@Composable +fun NearDropdownMenu( + modifier: Modifier = Modifier, + expanded: Boolean, + onDismissRequest: () -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + DropdownMenu( + modifier = modifier.background(NearTheme.colors.WHITE_FFFFFF), + expanded = expanded, + shape = RoundedCornerShape(12.dp), + onDismissRequest = onDismissRequest, + content = content, + ) +} + +@Composable +fun NearDropdownMenuItem( + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + DropdownMenuItem( + modifier = modifier, + onClick = onClick, + text = { + Text( + text = text, + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + }, + ) +} + +@Preview(showBackground = true) +@Composable +fun NearDropdownMenuPreview() { + var expanded by remember { mutableStateOf(true) } + + NearTheme { + Box( + modifier = Modifier + .fillMaxSize() + .wrapContentSize(Alignment.Center) + ) { + NearDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + NearDropdownMenuItem( + text = "친구 정보 수정", + onClick = { expanded = false } + ) + NearDropdownMenuItem( + text = "친구 삭제", + onClick = { expanded = false } + ) + } + } + } +}