Skip to content

feat: 공동 카테고리 함께하는 사람들, 친구 초대 구현 #783 #825

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 51 commits into from
May 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
91da369
ui: 카테고리 조회 화면의 함께하는 사람들 UI 구현
s6m1n May 7, 2025
34eec8e
feat: 닉네임으로 멤버를 검색하는 API 추가
s6m1n May 14, 2025
57e5587
ui: 컴포즈 기본 컴포넌트 추가
s6m1n May 14, 2025
7b50804
feat: 카테고리 프래그먼트에 컴포즈 뷰 연결
s6m1n May 14, 2025
cab580e
ui: 초대 화면에 필요한 이미지, 스트링 리소스 추가
s6m1n May 14, 2025
79c42aa
refactor: 공통 컴포넌트 컨벤션에 맞게 수정
s6m1n May 14, 2025
a861161
feat: 카테고리 멤버 초대를 위한 모델 및 테스트 추가
s6m1n May 14, 2025
fdbf751
ui: 초대 다이얼로그 상단바 구현
s6m1n May 14, 2025
ba85efd
ui: 사용자 검색을 위한 TextField 구현
s6m1n May 14, 2025
603916c
ui: 선택된 멤버 리스트 ui 구현
s6m1n May 14, 2025
75aba4b
ui: 검색된 멤버 리스트 ui 구현
s6m1n May 14, 2025
28f6ec9
feat: 멤버 초대 다이얼로그 구현
s6m1n May 14, 2025
f579ccc
Merge branch 'refs/heads/develop' into feature/#783-mate-invite
s6m1n May 14, 2025
1537a88
style: KtLint 컴포즈 설정
s6m1n May 14, 2025
7efeb1e
chore: 초대하기 선택 버튼 스트링 수정
s6m1n May 15, 2025
14460dc
refactor: updateCategory 중복 코루틴 제거
s6m1n May 15, 2025
1e29f61
test: 멤버 검색 및 선택 기능 ViewModel 테스트 구현
s6m1n May 15, 2025
0b4deec
test: 테스트 통과를 위해 CategoryFixture 수정
s6m1n May 15, 2025
ebd4880
feat: 공동 카테고리 참가자 Participant 객체 추가
s6m1n May 15, 2025
2bcb8b7
refactor: 카테고리 조회 API v3 및 Participant 적용
s6m1n May 15, 2025
9ff9e6f
ui: 함께하는 사람들 host ui 구현
s6m1n May 15, 2025
f3097fc
ui: 비어있는 카테고리 empty view 수정
s6m1n May 15, 2025
9f69340
test: test fixture 수정
s6m1n May 16, 2025
8cde712
ui: TopBar에 선택한 사용자 수 표시
s6m1n May 16, 2025
42b6337
ui: 코멘트 엠티뷰 수정 및 캐릭터 흑백 사진 해상도 개선
s6m1n May 16, 2025
59626aa
test: 학습 테스트 삭제
s6m1n May 16, 2025
5aa883f
feat: 카테고리 공유 여부에 따라 함께하는 사람들 표시
s6m1n May 16, 2025
80305b0
ui: DefaultDivider 컬러 변경
s6m1n May 21, 2025
aa1046f
feat: 사용자 역할에 따라 카테고리 초대, 수정, 삭제 권한 조정
s6m1n May 21, 2025
1333f7d
feat: 카테고리 멤버 초대 API 연결
s6m1n May 22, 2025
da2c451
feat: 카테고리 멤버 초대 기능 구현
s6m1n May 22, 2025
70a66db
Merge branch 'develop' into feature/#783-mate-invite
s6m1n May 22, 2025
af149c0
test: 테스트가 통과할 수 있도록 변경사항 반영
s6m1n May 22, 2025
100da19
chore: 띄어쓰기 수정
s6m1n May 28, 2025
99cc83e
refactor: category 프로퍼티 명 수정 member -> participants
s6m1n May 28, 2025
187f334
refactor: StaccatoSearchTextField -> SearchTextField 이름 변경
s6m1n May 28, 2025
9ead2cb
ui: 멤버 검색 결과 아이템 ui 긴 이름 대응
s6m1n May 28, 2025
7106520
ui: 사용자 검색 LazyColumn 애니메이션 제거
s6m1n May 28, 2025
59cdd6f
ui: 함께하는 사람들 리사이클러뷰 padding 수정 및 멤버 프로필 placeHolder 적용
s6m1n May 28, 2025
eca2d2d
ui: Material3 TextField에서 BasicTextField로 변경
s6m1n May 28, 2025
0f66b42
Merge branch 'develop' into feature/#783-mate-invite
s6m1n May 28, 2025
74916e3
ui: 폰트 weight 및 Preview 이름 수정
s6m1n May 28, 2025
173f41b
Merge branch 'develop' into feature/#783-mate-invite
s6m1n May 29, 2025
b557fe4
ui: 다이얼로그 내에서 실패 시 토스트로 띄우기
s6m1n May 29, 2025
98a74d1
ui: 다이얼로그 decorFitsSystemWindows 옵션 제거
s6m1n May 29, 2025
9b8e3f1
feat: 선택된 사람이 없으면 초대 요청을 보내지 않도록 수정
s6m1n May 29, 2025
4c3f669
style: 불필요한 개행 제거
s6m1n May 29, 2025
105741d
refactor: isGone 사용
s6m1n May 29, 2025
099bc59
chore: 오타 수정
s6m1n May 29, 2025
24e21b8
chore: preview 이름 수정
s6m1n May 29, 2025
ed7392a
chore: InviteDialog preview 중복 코드 개선
s6m1n May 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import retrofit2.http.Path
import retrofit2.http.Query

interface CategoryApiService {
@GET(CATEGORY_PATH_WITH_ID_V2)
@GET(CATEGORY_PATH_WITH_ID_V3)
suspend fun getCategory(
@Path(CATEGORY_ID) categoryId: Long,
): ApiResult<CategoryResponse>
Expand Down Expand Up @@ -56,7 +56,7 @@ interface CategoryApiService {
private const val CATEGORY_PATH_WITH_CANDIDATES = "$CATEGORIES_PATH$CANDIDATES_PATH"
private const val CURRENT_DATE = "currentDate"
private const val CATEGORIES_PATH_V2 = "/v2${CATEGORIES_PATH}"
private const val CATEGORY_PATH_WITH_ID_V2 = "/v2$CATEGORIES_PATH/{$CATEGORY_ID}"
private const val CATEGORY_PATH_WITH_ID_V3 = "/v3$CATEGORIES_PATH/{$CATEGORY_ID}"
private const val CATEGORIES_PATH_V3 = "/v3${CATEGORIES_PATH}"
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.on.staccato.data.dto.category

import com.on.staccato.data.dto.member.MemberDto
import com.on.staccato.data.dto.member.ParticipantDto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

Expand All @@ -13,6 +13,8 @@ data class CategoryResponse(
@SerialName("endAt") val endAt: String? = null,
@SerialName("description") val description: String? = null,
@SerialName("categoryColor") val color: String,
@SerialName("mates") val mates: List<MemberDto>,
@SerialName("members") val participants: List<ParticipantDto>,
@SerialName("staccatos") val staccatos: List<CategoryStaccatoDto>,
@SerialName("isShared") val isShared: Boolean,
@SerialName("myRole") val myRole: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.on.staccato.data.dto.invitation

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class InvitationRequest(
@SerialName("categoryId") val categoryId: Long,
@SerialName("inviteeIds") val inviteeIds: List<Long>,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.on.staccato.data.dto.invitation

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class InvitationResponse(
@SerialName("invitationIds") val invitationIds: List<Long>,
)
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ import com.on.staccato.data.dto.category.CategoriesResponse
import com.on.staccato.data.dto.category.CategoryRequest
import com.on.staccato.data.dto.category.CategoryResponse
import com.on.staccato.data.dto.category.CategoryStaccatoDto
import com.on.staccato.data.dto.member.ParticipantDto
import com.on.staccato.domain.model.Category
import com.on.staccato.domain.model.CategoryCandidate
import com.on.staccato.domain.model.CategoryCandidates
import com.on.staccato.domain.model.CategoryStaccato
import com.on.staccato.domain.model.Member
import com.on.staccato.domain.model.NewCategory
import com.on.staccato.domain.model.Participant
import com.on.staccato.domain.model.Role
import java.time.LocalDate
import java.time.LocalDateTime

Expand All @@ -21,8 +25,10 @@ fun CategoryResponse.toDomain() =
endAt = endAt?.let { LocalDate.parse(endAt) },
description = description,
color = color,
mates = mates.map { it.toDomain() },
participants = participants.map { it.toDomain() },
staccatos = staccatos.map { it.toDomain() },
isShared = isShared,
myRole = Role.of(myRole),
)

fun CategoriesResponse.toDomain(): CategoryCandidates =
Expand Down Expand Up @@ -55,3 +61,14 @@ fun NewCategory.toDto() =
endAt = endAt?.toString(),
isShared = isShared,
)

fun ParticipantDto.toDomain(): Participant =
Participant(
member =
Member(
memberId = id,
nickname = nickname,
memberImage = imageUrl,
),
role = Role.of(role),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.on.staccato.data.dto.member

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class MemberSearchResponse(
@SerialName("members") val members: List<MemberDto>,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.on.staccato.data.dto.member

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class ParticipantDto(
@SerialName("memberId") val id: Long,
@SerialName("nickname") val nickname: String,
@SerialName("memberImageUrl") val imageUrl: String? = null,
@SerialName("memberRole") val role: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.on.staccato.data.invitation

import com.on.staccato.data.dto.invitation.InvitationRequest
import com.on.staccato.data.dto.invitation.InvitationResponse
import com.on.staccato.data.network.ApiResult
import retrofit2.http.Body
import retrofit2.http.POST

interface InvitationApiService {
@POST(INVITATION_PATH)
suspend fun postInvitation(
@Body invitation: InvitationRequest,
): ApiResult<InvitationResponse>

companion object {
const val INVITATION_PATH = "/invitations"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.on.staccato.data.invitation

import com.on.staccato.data.dto.invitation.InvitationRequest
import com.on.staccato.data.network.ApiResult
import com.on.staccato.data.network.handle
import com.on.staccato.domain.repository.InvitationRepository
import javax.inject.Inject

class InvitationDefaultRepository
@Inject
constructor(
private val invitationApiService: InvitationApiService,
) : InvitationRepository {
Comment on lines +9 to +13
Copy link
Contributor

Choose a reason for hiding this comment

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

이 부분은 호두가 DataSource를 만들었다고 했던 것 같아서 충돌이 발생할 수도 있겠네요..! 머지 전에 한 번 같이 의논해보면 좋을 것 같아요~

@Junyoung-WON
@s6m1n

Copy link
Member Author

Choose a reason for hiding this comment

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

그렇군요! 저는 Repository 패턴에서 DataSource를 분리하는 이유는 데이터 출처가 여러 개일 수 있기 때문이라고 생각해요.
하지만 초대(Invite) 기능이 로컬 캐시의 필요도가 낮고, 초대는 보통 서버에서만 처리된다고 생각해서 DataSource 없이 바로 ApiService를 주입해주었습니다!

override suspend fun invite(
categoryId: Long,
inviteeIds: List<Long>,
): ApiResult<List<Long>> =
invitationApiService.postInvitation(InvitationRequest(categoryId, inviteeIds))
.handle { it.invitationIds }
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.on.staccato.data.member

import com.on.staccato.data.dto.member.MemberSearchResponse
import com.on.staccato.data.dto.member.RecoveryCodeResponse
import com.on.staccato.data.network.ApiResult
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query

Expand All @@ -11,8 +13,15 @@ interface MemberApiService {
@Query(RECOVERY_CODE) recoveryCode: String,
): ApiResult<RecoveryCodeResponse>

@GET(MEMBERS_SEARCH_PATH)
suspend fun getMembersBy(
@Query(NICKNAME) nickname: String,
): ApiResult<MemberSearchResponse>

companion object {
private const val MEMBERS_PATH = "/members"
private const val RECOVERY_CODE = "code"
private const val NICKNAME = "nickname"
private const val MEMBERS_SEARCH_PATH = "$MEMBERS_PATH/search"
Comment on lines +16 to +25
Copy link
Contributor

Choose a reason for hiding this comment

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

API Path를 동반객체의 문자열 상수로 깔끔하게 잘 표현해주셨네요! 👍

}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package com.on.staccato.data.member

import com.on.staccato.StaccatoApplication
import com.on.staccato.data.dto.mapper.toDomain
import com.on.staccato.data.network.ApiResult
import com.on.staccato.data.network.handle
import com.on.staccato.domain.model.Members
import com.on.staccato.domain.repository.MemberRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import javax.inject.Inject

class MemberDefaultRepository
Expand All @@ -12,5 +16,14 @@ class MemberDefaultRepository
private val memberApiService: MemberApiService,
) : MemberRepository {
override suspend fun fetchTokenWithRecoveryCode(recoveryCode: String): ApiResult<Unit> =
memberApiService.postRecoveryCode(recoveryCode).handle { StaccatoApplication.userInfoPrefsManager.setToken(it.token) }
memberApiService.postRecoveryCode(recoveryCode)
.handle { StaccatoApplication.userInfoPrefsManager.setToken(it.token) }

override suspend fun searchMembersBy(nickname: String): Flow<ApiResult<Members>> =
flow {
emit(
memberApiService.getMembersBy(nickname)
.handle { Members(it.members.map { it.toDomain() }) },
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.on.staccato.di.module
import com.on.staccato.data.category.CategoryDefaultRepository
import com.on.staccato.data.comment.CommentDefaultRepository
import com.on.staccato.data.image.ImageDefaultRepository
import com.on.staccato.data.invitation.InvitationDefaultRepository
import com.on.staccato.data.location.LocationDefaultRepository
import com.on.staccato.data.login.LoginDefaultRepository
import com.on.staccato.data.member.MemberDefaultRepository
Expand All @@ -12,6 +13,7 @@ import com.on.staccato.data.timeline.TimelineDefaultRepository
import com.on.staccato.domain.repository.CategoryRepository
import com.on.staccato.domain.repository.CommentRepository
import com.on.staccato.domain.repository.ImageRepository
import com.on.staccato.domain.repository.InvitationRepository
import com.on.staccato.domain.repository.LocationRepository
import com.on.staccato.domain.repository.LoginRepository
import com.on.staccato.domain.repository.MemberRepository
Expand Down Expand Up @@ -52,4 +54,7 @@ abstract class RepositoryModule {

@Binds
abstract fun bindLocationRepository(locationDefaultRepository: LocationDefaultRepository): LocationRepository

@Binds
abstract fun bindInvitationRepository(invitationDefaultRepository: InvitationDefaultRepository): InvitationRepository
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.on.staccato.StaccatoApplication.Companion.retrofit
import com.on.staccato.data.category.CategoryApiService
import com.on.staccato.data.comment.CommentApiService
import com.on.staccato.data.image.ImageApiService
import com.on.staccato.data.invitation.InvitationApiService
import com.on.staccato.data.login.LoginApiService
import com.on.staccato.data.member.MemberApiService
import com.on.staccato.data.mypage.MyPageApiService
Expand Down Expand Up @@ -49,4 +50,8 @@ object RetrofitModule {
@Singleton
@Provides
fun myPageApiService(): MyPageApiService = retrofit.create(MyPageApiService::class.java)

@Singleton
@Provides
fun invitationApiService(): InvitationApiService = retrofit.create(InvitationApiService::class.java)
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ data class Category(
val endAt: LocalDate? = null,
val description: String? = null,
val color: String,
val mates: List<Member>,
val participants: List<Participant>,
val staccatos: List<CategoryStaccato>,
val isShared: Boolean,
val myRole: Role,
)
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,12 @@ data class Member(
val nickname: String,
val memberImage: String? = null,
)

val dummyMember =
Member(
memberId = 0L,
nickname = "빙티",
memberImage = "",
)

val longNameMember = dummyMember.copy(nickname = "엄청나게아주매우아주매우아주매우아주매우아주매우닉네임")
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.on.staccato.domain.model

data class Members(val members: List<Member>) {
fun addFirst(member: Member): Members = Members((listOf(member) + members).distinct())
Copy link
Contributor

Choose a reason for hiding this comment

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

distinct 메서드로 중복을 제거해주셨네요!
중복 제거를 보니 Set을 사용하는 방법도 생각났지만, 사용자들을 정렬해야할 필요성도 있다고 한다면 지금 방식이 적절하겠네요!


fun filter(member: Member): Members = Members(members.filterNot { it.memberId == member.memberId })

fun contains(target: Member) = members.contains(target)
}

val emptyMembers = Members(emptyList())

val dummyMembers =
Members(
listOf(
dummyMember,
longNameMember.copy(memberId = 1L),
dummyMember.copy(memberId = 2L),
longNameMember.copy(memberId = 3L),
dummyMember.copy(memberId = 4L),
longNameMember.copy(memberId = 5L),
dummyMember.copy(memberId = 6L),
longNameMember.copy(memberId = 7L),
),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.on.staccato.domain.model

data class Participant(
val member: Member,
val role: Role,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.on.staccato.domain.model

data class Participants(val members: List<Participant>)

val emptyParticipants = Participants(emptyList())

fun Participants.toMembers() = Members(members.map { it.member })
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.on.staccato.domain.model

enum class Role(val value: String) {
HOST("host"),
GUEST("guest"),
;

companion object {
fun of(value: String) =
when (value) {
"host" -> HOST
"guest" -> GUEST
else -> throw IllegalArgumentException("유효하지 않은 Role 입니다.")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.on.staccato.domain.repository

import com.on.staccato.data.network.ApiResult

interface InvitationRepository {
suspend fun invite(
categoryId: Long,
inviteeIds: List<Long>,
): ApiResult<List<Long>>
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package com.on.staccato.domain.repository

import com.on.staccato.data.network.ApiResult
import com.on.staccato.domain.model.Members
import kotlinx.coroutines.flow.Flow

interface MemberRepository {
suspend fun fetchTokenWithRecoveryCode(recoveryCode: String): ApiResult<Unit>

suspend fun searchMembersBy(nickname: String): Flow<ApiResult<Members>>
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,30 @@ import android.content.res.ColorStateList
import android.graphics.PorterDuff
import android.graphics.drawable.Drawable
import android.net.Uri
import android.view.View
import android.widget.ImageButton
import android.widget.ImageView
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat.getColor
import androidx.core.view.isGone
Copy link
Contributor

Choose a reason for hiding this comment

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

앗 isGone이 사용되지 않고 있어요!
그런데 ktlint는 어떻게 통과한거지?? 🤔 🤔

Copy link
Contributor

Choose a reason for hiding this comment

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

혹시 isGone을 사용하지 않으신 이유가 따로 있으신가요? (순수 궁금!)

Copy link
Member Author

Choose a reason for hiding this comment

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

오잉 린트가 잠깐 졸았나... 이유는 따로 없습니다! 반영 완!

import androidx.databinding.BindingAdapter
import coil.load
import coil.transform.RoundedCornersTransformation
import com.on.staccato.R
import com.on.staccato.presentation.timeline.model.FilterType
import com.on.staccato.presentation.util.dpToPx

@BindingAdapter("isGone")
fun ImageView.setIsGone(isGone: Boolean) {
this.isGone = isGone
}

@BindingAdapter("isInvisible")
fun ImageView.setVisibility(isInvisible: Boolean) {
visibility = if (isInvisible) View.INVISIBLE else View.VISIBLE
}

@BindingAdapter("imageButtonIcon")
fun ImageButton.setColorSelectionIcon(
@DrawableRes drawableRes: Int,
Expand Down
Loading