diff --git a/core/data-api/src/main/java/com/droidknights/app/core/data/repository/api/ContributorRepository.kt b/core/data-api/src/main/java/com/droidknights/app/core/data/repository/api/ContributorRepository.kt index 86313598..534f449b 100644 --- a/core/data-api/src/main/java/com/droidknights/app/core/data/repository/api/ContributorRepository.kt +++ b/core/data-api/src/main/java/com/droidknights/app/core/data/repository/api/ContributorRepository.kt @@ -1,14 +1,12 @@ package com.droidknights.app.core.data.repository.api -import com.droidknights.app.core.model.Contributor -import com.droidknights.app.core.model.ContributorWithYears +import com.droidknights.app.core.model.ContributorGroup +import kotlinx.coroutines.flow.Flow interface ContributorRepository { - suspend fun getContributors( + fun flowContributors( owner: String, name: String, - ): List - - suspend fun getContributorsWithYears(): List + ): Flow> } diff --git a/core/data/src/main/assets/contributors.json b/core/data/src/main/assets/contributors.json index e1b3e5a4..27cc3267 100644 --- a/core/data/src/main/assets/contributors.json +++ b/core/data/src/main/assets/contributors.json @@ -1,186 +1,219 @@ [ { - "id":28249981, - "name":"laco-dev", + "login": "laco-dev", + "id": 28249981, "years":[ 2023 ] }, { - "id":32327475, - "name":"wisemuji", + "login": "wisemuji", + "id": 32327475, "years":[ 2023, 2024 ] }, { - "id":54518925, - "name":"l2hyunwoo", + "login": "KwakEuiJin", + "id": 93872496, "years":[ - 2023 + 2023, + 2024 ] }, { - "id":18674395, - "name":"JeonK1", + "login": "l2hyunwoo", + "id": 54518925, "years":[ - 2023 + 2023, + 2024 ] }, { - "id":93872496, - "name":"KwakEuiJin", + "login": "tmdgh1592", + "id": 56534241, "years":[ - 2023 + 2023, + 2024 + ] + }, + { + "login": "JeonK1", + "id": 18674395, + "years":[ + 2023, + 2024 ] }, { - "id":57751515, - "name":"yuuuzzzin", + "login": "taehwandev", + "id": 2144231, + "years":[ + 2023, + 2024 + ] + }, + { + "login": "rhkrwngud445", + "id": 33768873, + "years":[ + 2023, + 2024 + ] + }, + { + "login": "yuuuzzzin", + "id": 57751515, "years":[ 2023 ] }, { - "id":53253298, - "name":"toastmeister1", + "login": "toastmeister1", + "id": 53253298, "years":[ 2023 ] }, { - "id":72238126, - "name":"yjyoon-dev", + "login": "Cjsghkd", + "id": 84944098, "years":[ 2023 ] }, { - "id":84944098, - "name":"Cjsghkd", + "login": "yjyoon-dev", + "id": 72238126, "years":[ 2023 ] }, { - "id":22849063, - "name":"KimReady", + "login": "HamBP", + "id": 35232655, "years":[ 2024 ] }, { - "id":2144231, - "name":"taehwandev", + "login": "chattymin", + "id": 52882799, "years":[ - 2023, 2024 ] }, { - "id":37904970, - "name":"sodp5", + "login": "KimReady", + "id": 22849063, "years":[ - 2023 + 2024 ] }, { - "id":51078673, - "name":"JaesungLeee", + "login": "ParkJong-Hun", + "id": 81838716, "years":[ - 2023 + 2024 ] }, { - "id":61337202, - "name":"koreatlwls", + "login": "koreatlwls", + "id": 61337202, "years":[ 2023 ] }, { - "id":44341119, - "name":"malibinYun", + "login": "sodp5", + "id": 37904970, "years":[ 2023 ] }, { - "id":1534926, - "name":"Pluu", + "login": "JaesungLeee", + "id": 51078673, "years":[ 2023 ] }, { - "id":81838716, - "name":"ParkJong-Hun", + "login": "malibinYun", + "id": 44341119, "years":[ - 2024 + 2023 ] }, { - "id":92064758, - "name":"theo-taehwan", + "login": "taeheeL", + "id": 98825364, "years":[ - 2024 + 2023 ] }, { - "id":52663419, - "name":"jeongth9446", + "login": "Pluu", + "id": 1534926, "years":[ 2023 ] }, { - "id":4679634, - "name":"kisa002", + "login": "kisa002", + "id": 4679634, "years":[ 2023 ] }, { - "id":40175383, - "name":"gowoon-choi", + "login": "jeongth9446", + "id": 52663419, "years":[ 2023 ] }, { - "id":7759511, - "name":"workspace", + "login": "theo-taehwan", + "id": 92064758, + "years":[ + 2024 + ] + }, + { + "login": "gowoon-choi", + "id": 40175383, "years":[ 2023 ] }, { - "id":51016231, - "name":"easyhooon", + "login": "easyhooon", + "id": 51016231, "years":[ 2024 ] }, { - "id":33443660, - "name":"KwonDae", + "login": "workspace", + "id": 7759511, "years":[ 2023 ] }, { - "id":35232655, - "name":"HamBP", + "login": "KwonDae", + "id": 33443660, "years":[ - 2024 + 2023 ] }, { - "id":76798309, - "name":"onseok", + "login": "onseok", + "id": 76798309, "years":[ 2023 ] } -] \ No newline at end of file +] diff --git a/core/data/src/main/assets/sessions.json b/core/data/src/main/assets/sessions.json index d264f2b9..39efb808 100644 --- a/core/data/src/main/assets/sessions.json +++ b/core/data/src/main/assets/sessions.json @@ -19,7 +19,7 @@ "introduction": "Android & Kotlin GDE로 기여하고 있으며, Stream이라는 미국 회사에서 Lead Android Developer Advocate로 재직 중입니다. skydoves라는 닉네임으로 오픈소스 활동을 즐겨 하고 있습니다.", "imageUrl": "https://raw.githubusercontent.com/droidknights/DroidKnights2023_App/main/storage/speaker/엄재웅.png" } - ] + ], "tags": [ "Android" ], @@ -37,7 +37,7 @@ "introduction": "UI와 사용자 경험에 관심이 많은 개발자 입니다. 최근에는 디자인 시스템 개발에 참여하여 유저 경험 뿐만 아니라 동료의 개발 경험도 개선하고 있습니다.", "imageUrl": "https://raw.githubusercontent.com/droidknights/DroidKnights2023_App/main/storage/speaker/신현성.png" } - ] + ], "tags": [ "Android" ], @@ -55,7 +55,7 @@ "introduction": "코드스피츠를 운영하며 개발과 교육에 관심많은 일인입니다. 소소한 언어덕후로서 코틀린에 많은 흥미를 갖고 있습니다.", "imageUrl": "https://raw.githubusercontent.com/droidknights/DroidKnights2023_App/main/storage/speaker/히카맹.png" } - ] + ], "tags": [ "General" ], @@ -78,7 +78,7 @@ "introduction": "현재 Naver에서 Cafe 앱을 개발하고 있습니다.\nAndroid 개발자로 시작하여 6년정도 하고 있습니다.", "imageUrl": "https://raw.githubusercontent.com/droidknights/DroidKnights2023_App/main/storage/speaker/최재웅.png" } - ] + ], "tags": [ "General" ], @@ -96,7 +96,7 @@ "introduction": "현재는 네이버제트에서 제페토 서비스를 좀 더 빠르고 쾌적하게 즐길 수 있게 하기 위한 이런 저런 고민들을 하고 있습니다. 또한 개발 이야기 나누는 것을 좋아해서 GDG Korea Android 운영진으로도 활동하고 있습니다.", "imageUrl": "https://raw.githubusercontent.com/droidknights/DroidKnights2023_App/main/storage/speaker/배필주.png" } - ] + ], "tags": [ "Android" ], @@ -126,7 +126,7 @@ "introduction": "우아한형제들에서 우아한테크코스 모바일 안드로이드 교육 분야를 운영합니다. GDG Korea Android와 드로이드나이츠의 Organizer로 활동하고 있습니다.", "imageUrl": "https://raw.githubusercontent.com/droidknights/DroidKnights2023_App/main/storage/speaker/김수현.png" } - ] + ], "tags": [ "Android" ], @@ -144,7 +144,7 @@ "introduction": "안녕하세요. `안되는 건 없다! 된다고 하게`라는 마음으로 카카오뱅크에서 다양한 도전을 하고 있는 문종락입니다.", "imageUrl": "https://raw.githubusercontent.com/droidknights/DroidKnights2023_App/main/storage/speaker/문종락.png" } - ] + ], "tags": [ "Android" ], @@ -162,7 +162,7 @@ "introduction": "채널톡에서 고객과 기업을 이어주는 앱을 개발하고 있습니다. 고객사에서 고객과 손쉽게 채팅할 수 있는 채팅 SDK 뿐만 아니라, 상담사가 고객과 언제든 문의 답변을 할 수 있게끔 채팅 앱을 만들고 관리하고 있습니다.\n말리빈이라는 닉네임으로 교육 분야에서 활동하고 있으며, 가끔은 일상 유튜브 영상을 편집해 올리곤 합니다.", "imageUrl": "https://raw.githubusercontent.com/droidknights/DroidKnights2023_App/main/storage/speaker/윤혁.png" } - ] + ], "tags": [ "Android" ], @@ -190,7 +190,7 @@ "introduction": "안드로이드 2.0 시절, 앱 개발자로 안드로이드에 발을 들였습니다. 시간이 꽤 흘러 현재는 순수 개발 일 대신 Google Korea 개발자 관계 팀에서 앱 개발자 분들이 더 좋은 앱을 만들 수 있도록 돕는 일을 하고 있습니다. 개발자 커뮤니티 활동에 관심이 많고, 종종 안드로이드 관련된 내용을 소개하는 발표를 진행하기도 했습니다.", "imageUrl": "https://raw.githubusercontent.com/droidknights/DroidKnights2023_App/main/storage/speaker/양찬석.png" } - ] + ], "tags": [ "General" ], @@ -208,7 +208,7 @@ "introduction": "동료들이 효율적으로 일할 수 있도록 개발 환경에 대해 고민하고 개선하고 있어요.", "imageUrl": "https://raw.githubusercontent.com/droidknights/DroidKnights2023_App/main/storage/speaker/김태성.png" } - ] + ], "tags": [ "General" ], @@ -226,7 +226,7 @@ "introduction": "안녕하세요. 현재 at이라는 스타트업에서 프로덕트 엔지니어로 근무하고 있는 이상훈이라고 합니다. 7년차 안드로이드 개발자이며, 지금은 안드로이드와 iOS 등 모바일 쪽의 개발을 같이 하고 있습니다. 개발할 때 로우 레벨까지 내려가는 것을 좋아하며, 제일 좋아하는 분야는 최적화 분야입니다. 요즘은 Compose에서의 최적화와 KMP, CMP 찍먹을 해보고 있습니다.", "imageUrl": "https://raw.githubusercontent.com/droidknights/DroidKnights2023_App/main/storage/speaker/이상훈.png" } - ] + ], "tags": [ "Android" ], @@ -244,7 +244,7 @@ "introduction": "LINE Plus에서 안드로이드 개발자 분들의 업무 효율을 높이기 위한 여러가지 업무개선작업을 진행하고 있습니다.", "imageUrl": "https://raw.githubusercontent.com/droidknights/DroidKnights2023_App/main/storage/speaker/차영호.png" } - ] + ], "tags": [ "Android" ], @@ -262,7 +262,7 @@ "introduction": "현재 네이버에서 PRISM Player SDK를 개발하고 있습니다.\nKMP에 푹 빠져있으며, 공유 코드가 늘어날 때마다 흐뭇한 표정을 짓습니다.", "imageUrl": "https://raw.githubusercontent.com/droidknights/DroidKnights2023_App/main/storage/speaker/모진섭.png" } - ] + ], "tags": [ "Android" ], @@ -292,7 +292,7 @@ "introduction": "현재 컬리에 재직 중이며,\n기존 서비스에 Compose를 적용하고, KPDS(Kurly Product Design System)를 만들어 나가는 과정에서의 경험들을 공유합니다.", "imageUrl": "https://raw.githubusercontent.com/droidknights/DroidKnights2023_App/main/storage/speaker/권대원.png" } - ] + ], "tags": [ "Android" ], @@ -310,7 +310,7 @@ "introduction": "요새 운동에 푹빠진 ONE store에서 일하고있는 Android 개발자입니다.\n사용자의 인터렉션에 대해 관심이 많고, 남들에게 공유하는것을 좋아합니다.", "imageUrl": "https://raw.githubusercontent.com/droidknights/DroidKnights2023_App/main/storage/speaker/최우성.png" } - ] + ], "tags": [ "Android" ], @@ -328,7 +328,7 @@ "introduction": "안녕하세요, 학생 개발자 이현우입니다! 매스프레소(콴다)에서 2년동안 안드로이드 개발자로 재직한 경험이 있습니다. 현재는 GDSC 건국대학교 챕터의 리드를 맡고 있고 Kotlin User Groups Seoul의 오거나이저로 활동하고 있습니다.", "imageUrl": "https://raw.githubusercontent.com/droidknights/DroidKnights2023_App/main/storage/speaker/이현우.png" } - ] + ], "tags": [ "Android" ], diff --git a/core/data/src/main/java/com/droidknights/app/core/data/api/fake/AssetsGithubRawApi.kt b/core/data/src/main/java/com/droidknights/app/core/data/api/fake/AssetsGithubRawApi.kt index aa3637d7..544194db 100644 --- a/core/data/src/main/java/com/droidknights/app/core/data/api/fake/AssetsGithubRawApi.kt +++ b/core/data/src/main/java/com/droidknights/app/core/data/api/fake/AssetsGithubRawApi.kt @@ -1,6 +1,5 @@ package com.droidknights.app.core.data.api.fake -import android.content.Context import com.droidknights.app.core.data.api.GithubRawApi import com.droidknights.app.core.data.api.model.ContributionYearResponse import com.droidknights.app.core.data.api.model.SessionResponse @@ -8,15 +7,15 @@ import com.droidknights.app.core.data.api.model.SponsorResponse import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream +import java.io.InputStream @OptIn(ExperimentalSerializationApi::class) internal class AssetsGithubRawApi( - context: Context, private val json: Json, + private val sponsors: InputStream, + private val sessions: InputStream, + private val contributors: InputStream, ) : GithubRawApi { - private val sponsors = context.assets.open("sponsors.json") - private val sessions = context.assets.open("sessions.json") - private val contributors = context.assets.open("contributors.json") override suspend fun getSponsors(): List { return json.decodeFromStream(sponsors) diff --git a/core/data/src/main/java/com/droidknights/app/core/data/api/model/SessionResponse.kt b/core/data/src/main/java/com/droidknights/app/core/data/api/model/SessionResponse.kt index 5e3ceaed..4f201e3d 100644 --- a/core/data/src/main/java/com/droidknights/app/core/data/api/model/SessionResponse.kt +++ b/core/data/src/main/java/com/droidknights/app/core/data/api/model/SessionResponse.kt @@ -1,16 +1,17 @@ package com.droidknights.app.core.data.api.model import kotlinx.datetime.LocalDateTime +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable internal data class SessionResponse( - val id: String, - val title: String, - val content: String, - val speakers: List, - val tags: List, - val room: RoomResponse?, - val startTime: LocalDateTime, - val endTime: LocalDateTime, + @SerialName("id") val id: String, + @SerialName("title") val title: String, + @SerialName("content") val content: String, + @SerialName("speakers") val speakers: List, + @SerialName("tags") val tags: List, + @SerialName("room") val room: RoomResponse?, + @SerialName("startTime") val startTime: LocalDateTime, + @SerialName("endTime") val endTime: LocalDateTime, ) diff --git a/core/data/src/main/java/com/droidknights/app/core/data/api/model/SpeakerResponse.kt b/core/data/src/main/java/com/droidknights/app/core/data/api/model/SpeakerResponse.kt index caa6bb4d..b3e200f7 100644 --- a/core/data/src/main/java/com/droidknights/app/core/data/api/model/SpeakerResponse.kt +++ b/core/data/src/main/java/com/droidknights/app/core/data/api/model/SpeakerResponse.kt @@ -5,10 +5,7 @@ import kotlinx.serialization.Serializable @Serializable internal data class SpeakerResponse( - @SerialName("name") - val name: String, - @SerialName("introduction") - val introduction: String, - @SerialName("imageUrl") - val imageUrl: String, + @SerialName("name") val name: String, + @SerialName("introduction") val introduction: String, + @SerialName("imageUrl") val imageUrl: String, ) diff --git a/core/data/src/main/java/com/droidknights/app/core/data/di/DataModule.kt b/core/data/src/main/java/com/droidknights/app/core/data/di/DataModule.kt index 20ffb8ea..ef220538 100644 --- a/core/data/src/main/java/com/droidknights/app/core/data/di/DataModule.kt +++ b/core/data/src/main/java/com/droidknights/app/core/data/di/DataModule.kt @@ -70,6 +70,11 @@ internal abstract class DataModule { @ApplicationContext context: Context, json: Json, ): AssetsGithubRawApi = - AssetsGithubRawApi(context, json) + AssetsGithubRawApi( + json = json, + sponsors = context.assets.open("sponsors.json"), + sessions = context.assets.open("sessions.json"), + contributors = context.assets.open("contributors.json"), + ) } } diff --git a/core/data/src/main/java/com/droidknights/app/core/data/mapper/ContributorMapper.kt b/core/data/src/main/java/com/droidknights/app/core/data/mapper/ContributorMapper.kt index 5bd78a82..43c0d016 100644 --- a/core/data/src/main/java/com/droidknights/app/core/data/mapper/ContributorMapper.kt +++ b/core/data/src/main/java/com/droidknights/app/core/data/mapper/ContributorMapper.kt @@ -6,7 +6,7 @@ import com.droidknights.app.core.model.Contributor internal fun ContributorResponse.toData(): Contributor = Contributor( id = id, - name = this.name, - imageUrl = this.imageUrl, - githubUrl = this.githubUrl, + name = name, + imageUrl = imageUrl, + githubUrl = githubUrl, ) diff --git a/core/data/src/main/java/com/droidknights/app/core/data/repository/DefaultContributorRepository.kt b/core/data/src/main/java/com/droidknights/app/core/data/repository/DefaultContributorRepository.kt index 108e86c1..50ec66ea 100644 --- a/core/data/src/main/java/com/droidknights/app/core/data/repository/DefaultContributorRepository.kt +++ b/core/data/src/main/java/com/droidknights/app/core/data/repository/DefaultContributorRepository.kt @@ -4,8 +4,11 @@ import com.droidknights.app.core.data.api.GithubApi import com.droidknights.app.core.data.api.GithubRawApi import com.droidknights.app.core.data.mapper.toData import com.droidknights.app.core.data.repository.api.ContributorRepository -import com.droidknights.app.core.model.Contributor -import com.droidknights.app.core.model.ContributorWithYears +import com.droidknights.app.core.model.ContributorGroup +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import javax.inject.Inject internal class DefaultContributorRepository @Inject constructor( @@ -13,14 +16,43 @@ internal class DefaultContributorRepository @Inject constructor( private val githubRawApi: GithubRawApi ) : ContributorRepository { - override suspend fun getContributors( - owner: String, - name: String, - ): List { - return githubApi.getContributors(owner, name).map { it.toData() } - } + override fun flowContributors(owner: String, name: String): Flow> = + combine( + flow { + emit(githubApi.getContributors(owner, name)) + } + .map { list -> + list.map { it.toData() } + }, + flow { + emit(githubRawApi.getContributorWithYears()) + } + .map { list -> + list + .flatMap { data -> + data.years.map { year -> year to data.id } + } + .groupBy { it.first } // year 기준으로 그룹화 + .toSortedMap() + .mapValues { + it.value.distinctBy { it.second }.map { it.second } + } // 각 그룹의 id 리스트 만들고 중복 제거 + .toMap() + .toSortedMap(compareByDescending { it }) + } + ) { contributors, yearMap -> + // id를 기반으로 두 리스트 매칭 + val resultMap = contributors.associateBy { it.id } - override suspend fun getContributorsWithYears(): List { - return githubRawApi.getContributorWithYears().map { it.toData() } - } + // map1의 각 키에 해당하는 데이터 리스트 추출 및 Data 객체로 변환 + yearMap.mapValues { year -> + year.value.mapNotNull { id -> resultMap[id] } + } + yearMap.map { year -> + ContributorGroup( + year = year.key, + contributors = year.value.mapNotNull { id -> resultMap[id] }, + ) + } + } } diff --git a/core/data/src/test/java/com/droidknights/app/core/data/api/fake/FakeGithubRawApi.kt b/core/data/src/test/java/com/droidknights/app/core/data/api/fake/FakeGithubRawApi.kt index 3bae03fe..85667aa5 100644 --- a/core/data/src/test/java/com/droidknights/app/core/data/api/fake/FakeGithubRawApi.kt +++ b/core/data/src/test/java/com/droidknights/app/core/data/api/fake/FakeGithubRawApi.kt @@ -16,6 +16,7 @@ internal class FakeGithubRawApi( coerceInputValues = true }, ) : GithubRawApi { + private val sponsors = File("src/main/assets/sponsors.json") private val sessions = File("src/main/assets/sessions.json") private val contributors = File("src/main/assets/contributors.json") diff --git a/core/data/src/test/java/com/droidknights/app/core/data/repository/DefaultContributorRepositoryTest.kt b/core/data/src/test/java/com/droidknights/app/core/data/repository/DefaultContributorRepositoryTest.kt index b9d64d96..aa36b232 100644 --- a/core/data/src/test/java/com/droidknights/app/core/data/repository/DefaultContributorRepositoryTest.kt +++ b/core/data/src/test/java/com/droidknights/app/core/data/repository/DefaultContributorRepositoryTest.kt @@ -4,8 +4,10 @@ import com.droidknights.app.core.data.api.fake.FakeGithubApi import com.droidknights.app.core.data.api.fake.FakeGithubRawApi import com.droidknights.app.core.data.api.model.ContributorResponse import com.droidknights.app.core.model.Contributor +import com.droidknights.app.core.model.ContributorGroup import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.shouldBe +import kotlinx.coroutines.flow.first internal class DefaultContributorRepositoryTest : BehaviorSpec() { @@ -16,30 +18,63 @@ internal class DefaultContributorRepositoryTest : BehaviorSpec() { init { Given("컨트리뷰터가 존재한다") { - val expected = contributors - When("컨트리뷰터를 조회한다") { - val contributors: List = repository.getContributors( - owner = "droidknights", name = "app" - ) + val contributorList = repository.flowContributors( + owner = "droidknights", + name = "app", + ).first() Then("컨트리뷰터를 반환한다") { - contributors.size shouldBe 1 - contributors.all { - it.name == expected[0].name - } + contributorList.size shouldBe 2 + contributorList shouldBe listOf( + ContributorGroup( + year = 2024, + contributors = listOf( + Contributor( + name = "2024 - name", + imageUrl = "test image url", + githubUrl = "test github url", + id = 32327475 + ), + ), + ), + ContributorGroup( + year = 2023, + contributors = listOf( + Contributor( + name = "test name", + imageUrl = "test image url", + githubUrl = "test github url", + id = 28249981 + ), + Contributor( + name = "2024 - name", + imageUrl = "test image url", + githubUrl = "test github url", + id = 32327475 + ), + ), + ), + ) } } } } companion object { + private val contributors = listOf( ContributorResponse( name = "test name", imageUrl = "test image url", githubUrl = "test github url", - id = 0 - ) + id = 28249981 + ), + ContributorResponse( + name = "2024 - name", + imageUrl = "test image url", + githubUrl = "test github url", + id = 32327475 + ), ) } } diff --git a/core/domain/src/main/java/com/droidknights/app/core/domain/usecase/GetContributorsUseCase.kt b/core/domain/src/main/java/com/droidknights/app/core/domain/usecase/GetContributorsUseCase.kt index cb071f6d..2e4a73f7 100644 --- a/core/domain/src/main/java/com/droidknights/app/core/domain/usecase/GetContributorsUseCase.kt +++ b/core/domain/src/main/java/com/droidknights/app/core/domain/usecase/GetContributorsUseCase.kt @@ -1,30 +1,19 @@ package com.droidknights.app.core.domain.usecase import com.droidknights.app.core.data.repository.api.ContributorRepository -import com.droidknights.app.core.model.Contributor +import com.droidknights.app.core.model.ContributorGroup +import kotlinx.coroutines.flow.Flow import javax.inject.Inject class GetContributorsUseCase @Inject constructor( private val repository: ContributorRepository, ) { - suspend operator fun invoke(): List { - val contributors = repository.getContributors( + + operator fun invoke(): Flow> = + repository.flowContributors( owner = OWNER, name = NAME, ) - val contributorsWithYears = repository.getContributorsWithYears() - - val contributionYears = contributorsWithYears.flatMap { it.years }.toSet().sorted() - val latestYear = contributionYears.last() - - return contributors.filter { contributor -> - val years = contributorsWithYears - .find { it.id == contributor.id } - ?.years ?: listOf(latestYear) - - years.contains(latestYear) - } - } companion object { diff --git a/core/domain/src/test/java/com/droidknights/app/core/domain/usecase/FakeContributorRepository.kt b/core/domain/src/test/java/com/droidknights/app/core/domain/usecase/FakeContributorRepository.kt index 5c56b475..3441e08e 100644 --- a/core/domain/src/test/java/com/droidknights/app/core/domain/usecase/FakeContributorRepository.kt +++ b/core/domain/src/test/java/com/droidknights/app/core/domain/usecase/FakeContributorRepository.kt @@ -1,19 +1,14 @@ package com.droidknights.app.core.domain.usecase import com.droidknights.app.core.data.repository.api.ContributorRepository -import com.droidknights.app.core.model.Contributor -import com.droidknights.app.core.model.ContributorWithYears +import com.droidknights.app.core.model.ContributorGroup +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf internal class FakeContributorRepository( - private val contributors: List, - private val contributorWithYears: List + private val contributors: List, ) : ContributorRepository { - override suspend fun getContributors(owner: String, name: String): List { - return contributors - } - - override suspend fun getContributorsWithYears(): List { - return contributorWithYears - } + override fun flowContributors(owner: String, name: String): Flow> = + flowOf(contributors) } diff --git a/core/domain/src/test/java/com/droidknights/app/core/domain/usecase/GetContributorsUseCaseTest.kt b/core/domain/src/test/java/com/droidknights/app/core/domain/usecase/GetContributorsUseCaseTest.kt index 9904574c..0a7a74e3 100644 --- a/core/domain/src/test/java/com/droidknights/app/core/domain/usecase/GetContributorsUseCaseTest.kt +++ b/core/domain/src/test/java/com/droidknights/app/core/domain/usecase/GetContributorsUseCaseTest.kt @@ -1,60 +1,61 @@ package com.droidknights.app.core.domain.usecase import com.droidknights.app.core.model.Contributor -import com.droidknights.app.core.model.ContributorWithYears +import com.droidknights.app.core.model.ContributorGroup import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.shouldBe +import kotlinx.coroutines.flow.first internal class GetContributorsUseCaseTest : BehaviorSpec() { private val useCase: GetContributorsUseCase = GetContributorsUseCase( - repository = FakeContributorRepository(contributors, contributorsWithYears) + repository = FakeContributorRepository(mockContributors) ) init { Given("드로이드나이츠 컨트리뷰터가 존재한다") { When("드로이드나이츠 컨트리뷰터를 조회한다") { - val contributors: List = useCase.invoke() + val contributors = useCase.invoke().first() - Then("올해 드로이드나이츠 컨트리뷰터를 반환한다") { - contributors.size shouldBe 2 + Then("연도별 드로이드나이츠 컨트리뷰터를 반환한다") { + contributors shouldBe mockContributors } } } } companion object { - private val contributors = listOf( - Contributor( - id = 0L, - name = "test name", - imageUrl = "test image url", - githubUrl = "test github url", - ), - Contributor( - id = 1L, - name = "test name1", - imageUrl = "test image url1", - githubUrl = "test github url1", - ), - Contributor( - id = 2L, - name = "test name2", - imageUrl = "test image url2", - githubUrl = "test github url2", - ) - ) - private val contributorsWithYears = listOf( - ContributorWithYears( - id = 1L, - listOf(2023, 2024) + private val mockContributors = listOf( + ContributorGroup( + year = 2024, + contributors = listOf( + Contributor( + name = "2024 - name", + imageUrl = "test image url", + githubUrl = "test github url", + id = 32327475 + ), + ), + ), + ContributorGroup( + year = 2023, + contributors = listOf( + Contributor( + name = "test name", + imageUrl = "test image url", + githubUrl = "test github url", + id = 28249981 + ), + Contributor( + name = "2024 - name", + imageUrl = "test image url", + githubUrl = "test github url", + id = 32327475 + ), + ), ), - ContributorWithYears( - id = 2L, - listOf(2023) - ) ) } } diff --git a/core/model/src/main/java/com/droidknights/app/core/model/ContributorGroup.kt b/core/model/src/main/java/com/droidknights/app/core/model/ContributorGroup.kt new file mode 100644 index 00000000..92ecc7fc --- /dev/null +++ b/core/model/src/main/java/com/droidknights/app/core/model/ContributorGroup.kt @@ -0,0 +1,6 @@ +package com.droidknights.app.core.model + +data class ContributorGroup( + val year: Int, + val contributors: List, +) diff --git a/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/ContributorScreen.kt b/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/ContributorScreen.kt index 492b24c3..e46fcd9e 100644 --- a/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/ContributorScreen.kt +++ b/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/ContributorScreen.kt @@ -21,8 +21,8 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.droidknights.app.core.designsystem.component.BottomLogo -import com.droidknights.app.core.model.Contributor import com.droidknights.app.feature.contributor.component.ContributorCard +import com.droidknights.app.feature.contributor.component.ContributorSection import com.droidknights.app.feature.contributor.component.ContributorTopAppBar import com.droidknights.app.feature.contributor.component.ContributorTopBanner import com.droidknights.app.feature.contributor.model.ContributorsUiState @@ -93,54 +93,71 @@ private fun ContributorList( ContributorsUiState.Loading -> { items(SHIMMERING_ITEM_COUNT) { ContributorCard( - contributor = null, - modifier = Modifier.padding(horizontal = 8.dp) + contributor = ContributorsUiState.Contributors.Item.User.Default, + showPlaceholder = true, + modifier = Modifier + .padding(horizontal = 8.dp) ) } } is ContributorsUiState.Contributors -> { - items(uiState.contributors) { contributor -> - ContributorCard( - contributor = contributor, - modifier = Modifier.padding(horizontal = 8.dp), - ) + items(uiState.contributors) { item -> + when (item) { + is ContributorsUiState.Contributors.Item.Section -> { + ContributorSection( + section = item, + ) + } + + is ContributorsUiState.Contributors.Item.User -> { + ContributorCard( + contributor = item, + showPlaceholder = false, + modifier = Modifier + .padding(horizontal = 8.dp), + ) + } + } } } } item { - Footer(modifier = Modifier.padding(bottom = 16.dp)) + Box( + modifier = Modifier + .padding(bottom = 16.dp) + ) { + BottomLogo() + } } } } -@Composable -private fun Footer(modifier: Modifier = Modifier) { - Box(modifier = modifier) { - BottomLogo() - } -} - private const val SHIMMERING_ITEM_COUNT = 4 internal class ContributorPreviewParameterProvider : PreviewParameterProvider { override val values = sequenceOf( ContributorsUiState.Loading, ContributorsUiState.Contributors( - persistentListOf( - Contributor( - 0L, - "Contributor1", - "https://avatars.githubusercontent.com/u/25101514", - "https://github.com/droidknights", + contributors = persistentListOf( + ContributorsUiState.Contributors.Item.Section( + title = "2023", ), - Contributor( - 1L, - "Contributor2", - "https://avatars.githubusercontent.com/u/25101514", - - "https://github.com/droidknights", + ContributorsUiState.Contributors.Item.User( + id = 0L, + name = "Contributor1", + imageUrl = "https://avatars.githubusercontent.com/u/25101514", + githubUrl = "https://github.com/droidknights", + ), + ContributorsUiState.Contributors.Item.Section( + title = "2024", + ), + ContributorsUiState.Contributors.Item.User( + id = 1L, + name = "Contributor2", + imageUrl = "https://avatars.githubusercontent.com/u/25101514", + githubUrl = "https://github.com/droidknights", ), ) ) diff --git a/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/ContributorViewModel.kt b/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/ContributorViewModel.kt index 98d2d40c..246020a6 100644 --- a/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/ContributorViewModel.kt +++ b/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/ContributorViewModel.kt @@ -4,14 +4,13 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.droidknights.app.core.domain.usecase.GetContributorsUseCase import com.droidknights.app.feature.contributor.model.ContributorsUiState +import com.droidknights.app.feature.contributor.model.convert.toContributorsUiState import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @@ -25,14 +24,16 @@ class ContributorViewModel @Inject constructor( val errorFlow = _errorFlow.asSharedFlow() val uiState: StateFlow = - flow { emit(getContributorsUseCase().toPersistentList()) } - .map(ContributorsUiState::Contributors) + getContributorsUseCase() + .map { + it.toContributorsUiState() + } .catch { throwable -> _errorFlow.emit(throwable) } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = ContributorsUiState.Loading, + initialValue = ContributorsUiState.Loading ) } diff --git a/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/component/ContributorCard.kt b/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/component/ContributorCardComponent.kt similarity index 75% rename from feature/contributor/src/main/java/com/droidknights/app/feature/contributor/component/ContributorCard.kt rename to feature/contributor/src/main/java/com/droidknights/app/feature/contributor/component/ContributorCardComponent.kt index 6cd6b5fc..6a98316b 100644 --- a/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/component/ContributorCard.kt +++ b/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/component/ContributorCardComponent.kt @@ -22,17 +22,18 @@ import com.droidknights.app.core.designsystem.component.NetworkImage import com.droidknights.app.core.designsystem.component.TextChip import com.droidknights.app.core.designsystem.res.rememberPainterResource import com.droidknights.app.core.designsystem.theme.KnightsTheme -import com.droidknights.app.core.model.Contributor import com.droidknights.app.feature.contributor.R +import com.droidknights.app.feature.contributor.model.ContributorsUiState import com.valentinilk.shimmer.shimmer @Composable internal fun ContributorCard( - contributor: Contributor?, + contributor: ContributorsUiState.Contributors.Item.User, modifier: Modifier = Modifier, + showPlaceholder: Boolean, ) { val uriHandler = LocalUriHandler.current - val shimmerModifier = if (contributor == null) { + val shimmerModifier = if (showPlaceholder) { Modifier .clip(RoundedCornerShape(10.dp)) .shimmer() @@ -47,8 +48,8 @@ internal fun ContributorCard( ) KnightsCard( - enabled = contributor?.githubUrl?.isNotEmpty() ?: false, - onClick = { uriHandler.openUri(contributor?.githubUrl ?: return@KnightsCard) }, + enabled = contributor.githubUrl.isNotEmpty(), + onClick = { uriHandler.openUri(contributor.githubUrl) }, modifier = modifier, ) { Row { @@ -69,7 +70,7 @@ internal fun ContributorCard( modifier = shimmerModifier ) Text( - text = contributor?.name ?: " ".repeat(20), + text = contributor.name, style = KnightsTheme.typography.headlineSmallBL, color = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier @@ -79,7 +80,7 @@ internal fun ContributorCard( } NetworkImage( - imageUrl = contributor?.imageUrl, + imageUrl = contributor.imageUrl, placeholder = placeholder, modifier = Modifier .padding(16.dp) @@ -97,12 +98,22 @@ internal fun ContributorCard( private fun ContributorCardPreview() { KnightsTheme { ContributorCard( - contributor = Contributor( - id = 1L, - name = "Droid Knights", - imageUrl = "", - githubUrl = "" + contributor = ContributorsUiState.Contributors.Item.User( + id = 0L, + name = "Contributor1", + imageUrl = "https://avatars.githubusercontent.com/u/25101514", + githubUrl = "https://github.com/droidknights", ), + showPlaceholder = true, + ) + ContributorCard( + contributor = ContributorsUiState.Contributors.Item.User( + id = 0L, + name = "Contributor1", + imageUrl = "https://avatars.githubusercontent.com/u/25101514", + githubUrl = "https://github.com/droidknights", + ), + showPlaceholder = false, ) } } diff --git a/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/component/ContributorSectionComponent.kt b/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/component/ContributorSectionComponent.kt new file mode 100644 index 00000000..bd85c895 --- /dev/null +++ b/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/component/ContributorSectionComponent.kt @@ -0,0 +1,38 @@ +package com.droidknights.app.feature.contributor.component + +import android.content.res.Configuration +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.droidknights.app.core.designsystem.theme.KnightsTheme +import com.droidknights.app.feature.contributor.model.ContributorsUiState + +@Composable +internal fun ContributorSection( + section: ContributorsUiState.Contributors.Item.Section, +) { + Text( + text = section.title, + style = KnightsTheme.typography.headlineLargeEB, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier + .padding(start = 24.dp, end = 24.dp, top = 20.dp, bottom = 10.dp) + ) +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContributorCardPreview() { + KnightsTheme { + ContributorSection( + section = ContributorsUiState.Contributors.Item.Section( + title = "2024", + ), + ) + } +} diff --git a/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/component/ContributorTopAppBar.kt b/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/component/ContributorTopAppBarComponent.kt similarity index 100% rename from feature/contributor/src/main/java/com/droidknights/app/feature/contributor/component/ContributorTopAppBar.kt rename to feature/contributor/src/main/java/com/droidknights/app/feature/contributor/component/ContributorTopAppBarComponent.kt diff --git a/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/component/ContributorTopBanner.kt b/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/component/ContributorTopBannerComponent.kt similarity index 100% rename from feature/contributor/src/main/java/com/droidknights/app/feature/contributor/component/ContributorTopBanner.kt rename to feature/contributor/src/main/java/com/droidknights/app/feature/contributor/component/ContributorTopBannerComponent.kt diff --git a/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/model/ContributorsUiState.kt b/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/model/ContributorsUiState.kt index d21fedc6..c6bb7325 100644 --- a/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/model/ContributorsUiState.kt +++ b/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/model/ContributorsUiState.kt @@ -2,7 +2,6 @@ package com.droidknights.app.feature.contributor.model import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import com.droidknights.app.core.model.Contributor import kotlinx.collections.immutable.ImmutableList @Stable @@ -13,6 +12,35 @@ sealed interface ContributorsUiState { @Immutable data class Contributors( - val contributors: ImmutableList, - ) : ContributorsUiState + val contributors: ImmutableList, + ) : ContributorsUiState { + + @Stable + sealed interface Item { + + @Immutable + data class Section( + val title: String, + ) : Item + + @Immutable + data class User( + val id: Long, + val name: String, + val imageUrl: String, + val githubUrl: String, + ) : Item { + + companion object { + + val Default = User( + id = -1, + name = "", + imageUrl = "", + githubUrl = "", + ) + } + } + } + } } diff --git a/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/model/convert/ContributorsConvert.kt b/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/model/convert/ContributorsConvert.kt new file mode 100644 index 00000000..bae1bfe5 --- /dev/null +++ b/feature/contributor/src/main/java/com/droidknights/app/feature/contributor/model/convert/ContributorsConvert.kt @@ -0,0 +1,21 @@ +package com.droidknights.app.feature.contributor.model.convert + +import com.droidknights.app.core.model.ContributorGroup +import com.droidknights.app.feature.contributor.model.ContributorsUiState +import kotlinx.collections.immutable.toPersistentList + +internal fun List.toContributorsUiState(): ContributorsUiState = + ContributorsUiState.Contributors( + contributors = flatMap { (year, contributors) -> + sequenceOf( + ContributorsUiState.Contributors.Item.Section(title = year.toString()) + ) + contributors.map { item -> + ContributorsUiState.Contributors.Item.User( + id = item.id, + imageUrl = item.imageUrl, + githubUrl = item.githubUrl, + name = item.name + ) + } + }.toPersistentList(), + ) \ No newline at end of file diff --git a/feature/contributor/src/test/java/com/droidknights/app/feature/contributor/ContributorViewModelTest.kt b/feature/contributor/src/test/java/com/droidknights/app/feature/contributor/ContributorViewModelTest.kt index 709a3c15..4a65aeda 100644 --- a/feature/contributor/src/test/java/com/droidknights/app/feature/contributor/ContributorViewModelTest.kt +++ b/feature/contributor/src/test/java/com/droidknights/app/feature/contributor/ContributorViewModelTest.kt @@ -3,14 +3,16 @@ package com.droidknights.app.feature.contributor import app.cash.turbine.test import com.droidknights.app.core.domain.usecase.GetContributorsUseCase import com.droidknights.app.core.model.Contributor +import com.droidknights.app.core.model.ContributorGroup import com.droidknights.app.core.testing.rule.MainDispatcherRule import com.droidknights.app.feature.contributor.model.ContributorsUiState import io.mockk.coEvery import io.mockk.mockk -import kotlin.test.assertIs +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +import kotlin.test.assertIs internal class ContributorViewModelTest { @get:Rule @@ -22,7 +24,7 @@ internal class ContributorViewModelTest { @Test fun `컨트리뷰터 데이터를 확인할 수 있다`() = runTest { // given - coEvery { getContributorsUseCase() } returns fakeContributors + coEvery { getContributorsUseCase() } returns flowOf(fakeContributors) viewModel = ContributorViewModel(getContributorsUseCase) // when & then @@ -34,11 +36,16 @@ internal class ContributorViewModelTest { companion object { private val fakeContributors = listOf( - Contributor( - id = 0L, - name = "test name", - imageUrl = "test image url", - githubUrl = "test github url", + ContributorGroup( + year = 2024, + contributors = listOf( + Contributor( + id = 0L, + name = "test name", + imageUrl = "test image url", + githubUrl = "test github url", + ), + ), ) ) }