diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index cf21832f..f8a49ea5 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,3 +1,6 @@
+import java.util.Properties
+
+
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
@@ -7,6 +10,12 @@ plugins {
alias(libs.plugins.google.services)
}
+val localProperty =
+ Properties().apply {
+ val f = rootProject.file("local.properties")
+ if (f.exists()) f.inputStream().use { load(it) }
+ }
+
android {
namespace = "com.kikidan.todakun"
compileSdk = 37
@@ -22,7 +31,20 @@ android {
}
buildTypes {
+ debug {
+ // TODO CI 통과를 위해 공백을 넣음. 추후 CD 설정 시 재설정
+ val kakaoKey = localProperty.getProperty("KAKAO_NATIVE_APP_KEY_DEV") ?: ""
+ val appLinkHost = localProperty.getProperty("APP_LINK_HOST_DEV") ?: ""
+ manifestPlaceholders["KAKAO_APP_KEY"] = kakaoKey
+ manifestPlaceholders["APP_LINK_HOST"] = appLinkHost
+ }
release {
+ // TODO CI 통과를 위해 공백을 넣음. 추후 CD 설정 시 재설정
+ val kakaoKey = localProperty.getProperty("KAKAO_NATIVE_APP_KEY") ?: ""
+ val appLinkHost = localProperty.getProperty("APP_LINK_HOST") ?: ""
+ manifestPlaceholders["KAKAO_APP_KEY"] = kakaoKey
+ manifestPlaceholders["APP_LINK_HOST"] = appLinkHost
+
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
@@ -70,10 +92,12 @@ dependencies {
implementation(projects.feature.mypage)
implementation(projects.feature.onboarding)
implementation(projects.feature.auth)
+ implementation(projects.feature.sajuContents)
implementation(projects.feature.luckAction)
implementation(projects.feature.home)
implementation(projects.feature.chat)
implementation(projects.feature.notification)
+ implementation(projects.feature.sajuContents)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 87cae449..48bc342a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -23,6 +23,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemoteCompatibilityDataSourceImpl.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemoteCompatibilityDataSourceImpl.kt
new file mode 100644
index 00000000..297db6de
--- /dev/null
+++ b/core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemoteCompatibilityDataSourceImpl.kt
@@ -0,0 +1,36 @@
+package com.kikidan.data_remote.datasource
+
+import com.kikidan.data.datasource.RemoteCompatibilityDataSource
+import com.kikidan.data_remote.dto.compatibility.CompatibilityResponse
+import com.kikidan.data_remote.dto.compatibility.toDomain
+import com.kikidan.data_remote.util.bodyNotNull
+import com.kikidan.domain.model.compatibility.Compatibility
+import dagger.Lazy
+import io.ktor.client.HttpClient
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import javax.inject.Inject
+
+class RemoteCompatibilityDataSourceImpl
+ @Inject
+ constructor(
+ private val client: Lazy,
+ ) : RemoteCompatibilityDataSource {
+ override suspend fun postCompatibility(partnerLinkId: String): Compatibility =
+ client
+ .get()
+ .post("$COMPATIBILITIES_URL/$partnerLinkId")
+ .bodyNotNull()
+ .toDomain()
+
+ override suspend fun getCompatibility(compatibilityId: String): Compatibility =
+ client
+ .get()
+ .get("$COMPATIBILITIES_URL/$compatibilityId")
+ .bodyNotNull()
+ .toDomain()
+
+ companion object {
+ private const val COMPATIBILITIES_URL = "api/v1/compatibilities"
+ }
+ }
diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemoteDayFortuneDataSourceImpl.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemoteDayFortuneDataSourceImpl.kt
new file mode 100644
index 00000000..b7749cb0
--- /dev/null
+++ b/core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemoteDayFortuneDataSourceImpl.kt
@@ -0,0 +1,50 @@
+package com.kikidan.data_remote.datasource
+
+import com.kikidan.data.datasource.RemoteDayFortuneDataSource
+import com.kikidan.data_remote.dto.dayfortune.CreateDayFortuneRequest
+import com.kikidan.data_remote.dto.dayfortune.DayFortuneResponse
+import com.kikidan.data_remote.dto.dayfortune.toDomain
+import com.kikidan.data_remote.util.bodyNotNull
+import com.kikidan.domain.model.dayfortune.DayFortune
+import com.kikidan.domain.model.dayfortune.DayFortunePurpose
+import dagger.Lazy
+import io.ktor.client.HttpClient
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+import javax.inject.Inject
+
+class RemoteDayFortuneDataSourceImpl
+ @Inject
+ constructor(
+ private val client: Lazy,
+ ) : RemoteDayFortuneDataSource {
+ override suspend fun postDayFortunes(
+ purpose: DayFortunePurpose,
+ targetDates: List,
+ ): List =
+ client
+ .get()
+ .post(DAY_FORTUNES_URL) {
+ setBody(
+ CreateDayFortuneRequest(
+ purpose = purpose.name,
+ targetDates = targetDates.map { it.format(DateTimeFormatter.ISO_LOCAL_DATE) },
+ ),
+ )
+ }.bodyNotNull>()
+ .map { it.toDomain() }
+
+ override suspend fun getDayFortune(id: String): DayFortune =
+ client
+ .get()
+ .get("$DAY_FORTUNES_URL/$id")
+ .bodyNotNull()
+ .toDomain()
+
+ companion object {
+ private const val DAY_FORTUNES_URL = "api/v1/day-fortunes"
+ }
+ }
diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemotePartnerSajuDataSourceImpl.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemotePartnerSajuDataSourceImpl.kt
index f0435c67..02453558 100644
--- a/core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemotePartnerSajuDataSourceImpl.kt
+++ b/core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemotePartnerSajuDataSourceImpl.kt
@@ -5,12 +5,15 @@ import com.kikidan.data_remote.dto.CommonResponse
import com.kikidan.data_remote.dto.saju.PartnerSajuDetailResponse
import com.kikidan.data_remote.dto.saju.PartnerSajuSummaryResponse
import com.kikidan.data_remote.dto.saju.RegisterPartnerSajuResponse
+import com.kikidan.data_remote.dto.saju.SajuChartDetailResponse
+import com.kikidan.data_remote.dto.saju.toChartDetail
import com.kikidan.data_remote.dto.saju.toDomain
import com.kikidan.data_remote.dto.saju.toRegisterRequest
import com.kikidan.data_remote.dto.saju.toUpdateRequest
import com.kikidan.data_remote.util.bodyNotNull
import com.kikidan.domain.model.saju.PartnerSaju
import com.kikidan.domain.model.saju.PartnerSajuInput
+import com.kikidan.domain.model.saju.SajuChartDetail
import dagger.Lazy
import io.ktor.client.HttpClient
import io.ktor.client.call.body
@@ -40,6 +43,13 @@ class RemotePartnerSajuDataSourceImpl
.bodyNotNull()
.toDomain()
+ override suspend fun getPartnerSajuChartDetail(linkId: String): SajuChartDetail =
+ client
+ .get()
+ .get(partnerUrl(linkId))
+ .bodyNotNull()
+ .toChartDetail()
+
override suspend fun deletePartnerSaju(linkId: String) {
client.get().delete(partnerUrl(linkId)).body>()
}
diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemoteYearFortuneDataSourceImpl.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemoteYearFortuneDataSourceImpl.kt
new file mode 100644
index 00000000..fa55e3d1
--- /dev/null
+++ b/core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemoteYearFortuneDataSourceImpl.kt
@@ -0,0 +1,32 @@
+package com.kikidan.data_remote.datasource
+
+import com.kikidan.data.datasource.RemoteYearFortuneDataSource
+import com.kikidan.data_remote.dto.fortune.YearFortuneResponse
+import com.kikidan.data_remote.dto.fortune.toDomain
+import com.kikidan.data_remote.util.bodyNotNull
+import com.kikidan.domain.model.fortune.YearFortune
+import dagger.Lazy
+import io.ktor.client.HttpClient
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import javax.inject.Inject
+
+class RemoteYearFortuneDataSourceImpl
+ @Inject
+ constructor(
+ private val client: Lazy,
+ ) : RemoteYearFortuneDataSource {
+ override suspend fun postYearFortune(year: Int): YearFortune =
+ client
+ .get()
+ .post("api/v1/year-fortunes/$year")
+ .bodyNotNull()
+ .toDomain()
+
+ override suspend fun getYearFortune(id: String): YearFortune =
+ client
+ .get()
+ .get("api/v1/year-fortunes/$id")
+ .bodyNotNull()
+ .toDomain()
+ }
diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/di/RemoteDataSourceModule.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/di/RemoteDataSourceModule.kt
index c49ed1f9..143e6524 100644
--- a/core/data-remote/src/main/java/com/kikidan/data_remote/di/RemoteDataSourceModule.kt
+++ b/core/data-remote/src/main/java/com/kikidan/data_remote/di/RemoteDataSourceModule.kt
@@ -3,6 +3,8 @@ package com.kikidan.data_remote.di
import com.kikidan.data.auth.AuthTokenCacheInvalidator
import com.kikidan.data.datasource.RemoteAuthDataSource
import com.kikidan.data.datasource.RemoteChatDataSource
+import com.kikidan.data.datasource.RemoteCompatibilityDataSource
+import com.kikidan.data.datasource.RemoteDayFortuneDataSource
import com.kikidan.data.datasource.RemoteDeviceTokenDataSource
import com.kikidan.data.datasource.RemoteFortuneDataSource
import com.kikidan.data.datasource.RemoteLuckActionDataSource
@@ -10,9 +12,12 @@ import com.kikidan.data.datasource.RemoteNotificationDataSource
import com.kikidan.data.datasource.RemotePartnerSajuDataSource
import com.kikidan.data.datasource.RemoteSajuDataSource
import com.kikidan.data.datasource.RemoteUserDataSource
+import com.kikidan.data.datasource.RemoteYearFortuneDataSource
import com.kikidan.data_remote.auth.BearerTokenCacheInvalidator
import com.kikidan.data_remote.datasource.RemoteAuthDataSourceImpl
import com.kikidan.data_remote.datasource.RemoteChatDataSourceImpl
+import com.kikidan.data_remote.datasource.RemoteCompatibilityDataSourceImpl
+import com.kikidan.data_remote.datasource.RemoteDayFortuneDataSourceImpl
import com.kikidan.data_remote.datasource.RemoteDeviceTokenDataSourceImpl
import com.kikidan.data_remote.datasource.RemoteFortuneDataSourceImpl
import com.kikidan.data_remote.datasource.RemoteLuckActionDataSourceImpl
@@ -20,6 +25,7 @@ import com.kikidan.data_remote.datasource.RemoteNotificationDataSourceImpl
import com.kikidan.data_remote.datasource.RemotePartnerSajuDataSourceImpl
import com.kikidan.data_remote.datasource.RemoteSajuDataSourceImpl
import com.kikidan.data_remote.datasource.RemoteUserDataSourceImpl
+import com.kikidan.data_remote.datasource.RemoteYearFortuneDataSourceImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@@ -37,6 +43,14 @@ abstract class RemoteDataSourceModule {
@Singleton
abstract fun bindAuthTokenCacheInvalidator(impl: BearerTokenCacheInvalidator): AuthTokenCacheInvalidator
+ @Binds
+ @Singleton
+ abstract fun bindYearFortuneRemoteDataSource(impl: RemoteYearFortuneDataSourceImpl): RemoteYearFortuneDataSource
+
+ @Binds
+ @Singleton
+ abstract fun bindDayFortuneRemoteDataSource(impl: RemoteDayFortuneDataSourceImpl): RemoteDayFortuneDataSource
+
@Binds
@Singleton
abstract fun bindFortuneRemoteDataSource(impl: RemoteFortuneDataSourceImpl): RemoteFortuneDataSource
@@ -68,4 +82,10 @@ abstract class RemoteDataSourceModule {
@Binds
@Singleton
abstract fun bindDeviceTokenDataSource(impl: RemoteDeviceTokenDataSourceImpl): RemoteDeviceTokenDataSource
+
+ @Binds
+ @Singleton
+ abstract fun bindCompatibilityRemoteDataSource(
+ impl: RemoteCompatibilityDataSourceImpl,
+ ): RemoteCompatibilityDataSource
}
diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/compatibility/CompatibilityResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/compatibility/CompatibilityResponse.kt
new file mode 100644
index 00000000..4cb0c3db
--- /dev/null
+++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/compatibility/CompatibilityResponse.kt
@@ -0,0 +1,43 @@
+package com.kikidan.data_remote.dto.compatibility
+
+import com.kikidan.data_remote.dto.saju.ElementResponse
+import com.kikidan.data_remote.dto.saju.RelationshipTypeResponse
+import com.kikidan.data_remote.dto.saju.toOhaeng
+import com.kikidan.domain.model.compatibility.Compatibility
+import com.kikidan.domain.model.compatibility.CompatibilityOhaeng
+import com.kikidan.domain.model.saju.RelationshipType
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class CompatibilityResponse(
+ val id: String,
+ val partnerName: String,
+ val relationshipType: RelationshipTypeResponse,
+ val score: Int,
+ val headline: String,
+ val subheadline: String,
+ val summary: String,
+ val totalAnalysis: String,
+ val analysisBasis: String,
+ val ohaengs: List,
+)
+
+@Serializable
+data class CompatibilityOhaengResponse(
+ val element: ElementResponse,
+ val percentage: Int,
+)
+
+fun CompatibilityResponse.toDomain(): Compatibility =
+ Compatibility(
+ id = id,
+ partnerName = partnerName,
+ relationshipType = RelationshipType(code = relationshipType.code, label = relationshipType.label),
+ score = score,
+ headline = headline,
+ subheadline = subheadline,
+ summary = summary,
+ totalAnalysis = totalAnalysis,
+ analysisBasis = analysisBasis,
+ ohaengs = ohaengs.map { CompatibilityOhaeng(ohaeng = it.element.code.toOhaeng(), percentage = it.percentage) },
+ )
diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/dayfortune/CreateDayFortuneRequest.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/dayfortune/CreateDayFortuneRequest.kt
new file mode 100644
index 00000000..c545f6ca
--- /dev/null
+++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/dayfortune/CreateDayFortuneRequest.kt
@@ -0,0 +1,9 @@
+package com.kikidan.data_remote.dto.dayfortune
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class CreateDayFortuneRequest(
+ val purpose: String,
+ val targetDates: List,
+)
diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/dayfortune/DayFortuneResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/dayfortune/DayFortuneResponse.kt
new file mode 100644
index 00000000..2887d86b
--- /dev/null
+++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/dayfortune/DayFortuneResponse.kt
@@ -0,0 +1,43 @@
+package com.kikidan.data_remote.dto.dayfortune
+
+import com.kikidan.domain.model.dayfortune.DayFortune
+import com.kikidan.domain.model.dayfortune.DayFortunePurpose
+import com.kikidan.domain.model.fortune.FortuneCategory
+import com.kikidan.domain.model.fortune.FortuneCategoryStar
+import kotlinx.serialization.Serializable
+import java.time.LocalDate
+
+@Serializable
+data class DayFortuneResponse(
+ val id: String,
+ val purpose: String,
+ val targetDate: String,
+ val score: Int,
+ val title: String,
+ val content: String,
+ val fortuneCategories: List,
+)
+
+@Serializable
+data class FortuneCategoryStarResponse(
+ val fortuneCategory: String,
+ val star: Int,
+)
+
+fun DayFortuneResponse.toDomain(): DayFortune =
+ DayFortune(
+ id = id,
+ purpose =
+ DayFortunePurpose.entries.find { it.name == purpose }
+ ?: throw IllegalArgumentException("알 수 없는 purpose 입니다: $purpose"),
+ targetDate = LocalDate.parse(targetDate),
+ score = score,
+ title = title,
+ content = content,
+ categoryStars = fortuneCategories.mapNotNull { it.toDomainOrNull() },
+ )
+
+private fun FortuneCategoryStarResponse.toDomainOrNull(): FortuneCategoryStar? {
+ val category = FortuneCategory.entries.find { it.name == fortuneCategory } ?: return null
+ return FortuneCategoryStar(category = category, star = star)
+}
diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/fortune/YearFortuneResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/fortune/YearFortuneResponse.kt
new file mode 100644
index 00000000..b2040259
--- /dev/null
+++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/fortune/YearFortuneResponse.kt
@@ -0,0 +1,41 @@
+package com.kikidan.data_remote.dto.fortune
+
+import com.kikidan.domain.model.fortune.FortuneCategory
+import com.kikidan.domain.model.fortune.FortuneCategoryStar
+import com.kikidan.domain.model.fortune.YearFortune
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class YearFortuneResponse(
+ val id: String,
+ val year: Int,
+ val score: Int,
+ val title: String,
+ val content: String,
+ val fortuneCategories: List,
+)
+
+@Serializable
+data class FortuneCategoryStarResponse(
+ val fortuneCategory: String,
+ val star: Int,
+)
+
+fun YearFortuneResponse.toDomain(): YearFortune =
+ YearFortune(
+ id = id,
+ year = year,
+ score = score,
+ title = title,
+ content = content,
+ categories = fortuneCategories.mapNotNull { it.toDomain() },
+ )
+
+// 서버가 새 카테고리를 추가해도 valueOf() 크래시 대신 알 수 없는 카테고리만 스킵한다.
+fun FortuneCategoryStarResponse.toDomain(): FortuneCategoryStar? {
+ val category = FortuneCategory.entries.find { it.name == fortuneCategory } ?: return null
+ return FortuneCategoryStar(
+ category = category,
+ star = star,
+ )
+}
diff --git a/core/data-remote/src/test/java/com/kikidan/data_remote/datasource/RemoteDayFortuneDataSourceImplTest.kt b/core/data-remote/src/test/java/com/kikidan/data_remote/datasource/RemoteDayFortuneDataSourceImplTest.kt
new file mode 100644
index 00000000..cca82b76
--- /dev/null
+++ b/core/data-remote/src/test/java/com/kikidan/data_remote/datasource/RemoteDayFortuneDataSourceImplTest.kt
@@ -0,0 +1,265 @@
+package com.kikidan.data_remote.datasource
+
+import com.kikidan.data_remote.di.TodakunJson
+import com.kikidan.data_remote.di.installTodakunDefaults
+import com.kikidan.domain.model.dayfortune.DayFortunePurpose
+import com.kikidan.domain.model.fortune.FortuneCategory
+import dagger.Lazy
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.MockRequestHandler
+import io.ktor.client.engine.mock.respond
+import io.ktor.http.ContentType
+import io.ktor.http.HttpHeaders
+import io.ktor.http.HttpMethod
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.content.OutgoingContent
+import io.ktor.http.headersOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.time.LocalDate
+
+class RemoteDayFortuneDataSourceImplTest {
+ private val json = TodakunJson
+ private val baseUrl = "https://test.example.com/"
+ private val jsonHeaders = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
+
+ private val purpose = DayFortunePurpose.TRAVEL
+ private val targetDates = listOf(LocalDate.of(2026, 8, 10), LocalDate.of(2026, 8, 12))
+
+ private fun buildSut(handler: MockRequestHandler): RemoteDayFortuneDataSourceImpl {
+ val engine = MockEngine(handler)
+ val client = HttpClient(engine) { installTodakunDefaults(json, baseUrl) }
+ return RemoteDayFortuneDataSourceImpl(Lazy { client })
+ }
+
+ @Test
+ fun `요청_경로와_메서드가_api_v1_day-fortunes_POST_이다`() =
+ runTest {
+ // given
+ var capturedUrl: String? = null
+ var capturedMethod: HttpMethod? = null
+ val sut =
+ buildSut { request ->
+ capturedUrl = request.url.encodedPath
+ capturedMethod = request.method
+ respond(
+ content =
+ """{"success":true,"code":"200","message":"ok","data":[]}""",
+ status = HttpStatusCode.OK,
+ headers = jsonHeaders,
+ )
+ }
+
+ // when
+ sut.postDayFortunes(purpose, targetDates)
+
+ // then
+ assertEquals("/api/v1/day-fortunes", capturedUrl)
+ assertEquals(HttpMethod.Post, capturedMethod)
+ }
+
+ @Test
+ fun `요청_body의_purpose는_enum_name_문자열이고_targetDates는_yyyy-MM-dd_배열이다`() =
+ runTest {
+ // given
+ var capturedBody: String? = null
+ val sut =
+ buildSut { request ->
+ capturedBody = String((request.body as OutgoingContent.ByteArrayContent).bytes())
+ respond(
+ content = """{"success":true,"code":"200","message":"ok","data":[]}""",
+ status = HttpStatusCode.OK,
+ headers = jsonHeaders,
+ )
+ }
+
+ // when
+ sut.postDayFortunes(purpose, targetDates)
+
+ // then
+ assertEquals(
+ """{"purpose":"TRAVEL","targetDates":["2026-08-10","2026-08-12"]}""",
+ capturedBody,
+ )
+ }
+
+ @Test
+ fun `정상_응답이면_targetDate가_LocalDate로_fortuneCategory가_enum으로_매핑된다`() =
+ runTest {
+ // given
+ val body =
+ """{"success":true,"code":"200","message":"ok","data":[""" +
+ """{"id":"id-1","purpose":"TRAVEL","targetDate":"2026-08-10","score":85,""" +
+ """"title":"title","content":"content",""" +
+ """"fortuneCategories":[{"fortuneCategory":"MONEY","star":3}]}]}"""
+ val sut =
+ buildSut {
+ respond(content = body, status = HttpStatusCode.OK, headers = jsonHeaders)
+ }
+
+ // when
+ val result = sut.postDayFortunes(purpose, targetDates)
+
+ // then
+ assertEquals(1, result.size)
+ val dayFortune = result.first()
+ assertEquals(LocalDate.of(2026, 8, 10), dayFortune.targetDate)
+ assertEquals(DayFortunePurpose.TRAVEL, dayFortune.purpose)
+ assertEquals(FortuneCategory.MONEY, dayFortune.categoryStars.first().category)
+ assertEquals(3, dayFortune.categoryStars.first().star)
+ }
+
+ @Test
+ fun `data가_null이면_IllegalArgumentException이_throw된다`() =
+ runTest {
+ // given
+ val body = """{"success":true,"code":"200","message":"ok"}"""
+ val sut =
+ buildSut {
+ respond(content = body, status = HttpStatusCode.OK, headers = jsonHeaders)
+ }
+
+ // when
+ val result = runCatching { sut.postDayFortunes(purpose, targetDates) }
+
+ // then
+ assertTrue(result.exceptionOrNull() is IllegalArgumentException)
+ }
+
+ @Test
+ fun `알_수_없는_fortuneCategory는_해당_항목만_제외되고_나머지는_정상_매핑된다`() =
+ runTest {
+ // given
+ val body =
+ """{"success":true,"code":"200","message":"ok","data":[""" +
+ """{"id":"id-1","purpose":"TRAVEL","targetDate":"2026-08-10","score":85,""" +
+ """"title":"title","content":"content",""" +
+ """"fortuneCategories":[{"fortuneCategory":"UNKNOWN_CATEGORY","star":1},""" +
+ """{"fortuneCategory":"MONEY","star":3}]}]}"""
+ val sut =
+ buildSut {
+ respond(content = body, status = HttpStatusCode.OK, headers = jsonHeaders)
+ }
+
+ // when
+ val result = sut.postDayFortunes(purpose, targetDates)
+
+ // then
+ assertEquals(1, result.first().categoryStars.size)
+ assertEquals(
+ FortuneCategory.MONEY,
+ result
+ .first()
+ .categoryStars
+ .first()
+ .category,
+ )
+ }
+
+ @Test
+ fun `알_수_없는_purpose이면_예외가_throw된다`() =
+ runTest {
+ // given
+ val body =
+ """{"success":true,"code":"200","message":"ok","data":[""" +
+ """{"id":"id-1","purpose":"UNKNOWN_PURPOSE","targetDate":"2026-08-10","score":85,""" +
+ """"title":"title","content":"content","fortuneCategories":[]}]}"""
+ val sut =
+ buildSut {
+ respond(content = body, status = HttpStatusCode.OK, headers = jsonHeaders)
+ }
+
+ // when
+ val result = runCatching { sut.postDayFortunes(purpose, targetDates) }
+
+ // then
+ assertTrue(result.exceptionOrNull() is IllegalArgumentException)
+ }
+
+ @Test
+ fun `5xx_응답이면_예외가_삼켜지지_않고_그대로_throw된다`() =
+ runTest {
+ // given
+ val sut =
+ buildSut {
+ respond("", HttpStatusCode.InternalServerError)
+ }
+
+ // when
+ val result = runCatching { sut.postDayFortunes(purpose, targetDates) }
+
+ // then
+ assertTrue(result.isFailure)
+ }
+
+ @Test
+ fun `getDayFortune_요청_경로와_메서드가_api_v1_day-fortunes_id_GET_이다`() =
+ runTest {
+ // given
+ var capturedUrl: String? = null
+ var capturedMethod: HttpMethod? = null
+ val sut =
+ buildSut { request ->
+ capturedUrl = request.url.encodedPath
+ capturedMethod = request.method
+ respond(
+ content =
+ """{"success":true,"code":"200","message":"ok","data":""" +
+ """{"id":"id-1","purpose":"TRAVEL","targetDate":"2026-08-10","score":85,""" +
+ """"title":"title","content":"content","fortuneCategories":[]}}""",
+ status = HttpStatusCode.OK,
+ headers = jsonHeaders,
+ )
+ }
+
+ // when
+ sut.getDayFortune("id-1")
+
+ // then
+ assertEquals("/api/v1/day-fortunes/id-1", capturedUrl)
+ assertEquals(HttpMethod.Get, capturedMethod)
+ }
+
+ @Test
+ fun `getDayFortune_정상_응답이면_targetDate가_LocalDate로_매핑된다`() =
+ runTest {
+ // given
+ val body =
+ """{"success":true,"code":"200","message":"ok","data":""" +
+ """{"id":"id-1","purpose":"TRAVEL","targetDate":"2026-08-10","score":85,""" +
+ """"title":"title","content":"content",""" +
+ """"fortuneCategories":[{"fortuneCategory":"MONEY","star":3}]}}"""
+ val sut =
+ buildSut {
+ respond(content = body, status = HttpStatusCode.OK, headers = jsonHeaders)
+ }
+
+ // when
+ val result = sut.getDayFortune("id-1")
+
+ // then
+ assertEquals(LocalDate.of(2026, 8, 10), result.targetDate)
+ assertEquals(DayFortunePurpose.TRAVEL, result.purpose)
+ assertEquals(FortuneCategory.MONEY, result.categoryStars.first().category)
+ }
+
+ @Test
+ fun `getDayFortune_data가_null이면_IllegalArgumentException이_throw된다`() =
+ runTest {
+ // given
+ val body = """{"success":true,"code":"200","message":"ok"}"""
+ val sut =
+ buildSut {
+ respond(content = body, status = HttpStatusCode.OK, headers = jsonHeaders)
+ }
+
+ // when
+ val result = runCatching { sut.getDayFortune("id-1") }
+
+ // then
+ assertTrue(result.exceptionOrNull() is IllegalArgumentException)
+ }
+}
diff --git a/core/data-remote/src/test/java/com/kikidan/data_remote/datasource/RemoteYearFortuneDataSourceImplTest.kt b/core/data-remote/src/test/java/com/kikidan/data_remote/datasource/RemoteYearFortuneDataSourceImplTest.kt
new file mode 100644
index 00000000..582bf3d3
--- /dev/null
+++ b/core/data-remote/src/test/java/com/kikidan/data_remote/datasource/RemoteYearFortuneDataSourceImplTest.kt
@@ -0,0 +1,140 @@
+package com.kikidan.data_remote.datasource
+
+import com.kikidan.data_remote.di.TodakunJson
+import com.kikidan.data_remote.di.installTodakunDefaults
+import com.kikidan.domain.model.fortune.FortuneCategory
+import com.kikidan.domain.model.fortune.FortuneCategoryStar
+import com.kikidan.domain.model.fortune.YearFortune
+import dagger.Lazy
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.MockRequestHandler
+import io.ktor.client.engine.mock.respond
+import io.ktor.http.ContentType
+import io.ktor.http.HttpHeaders
+import io.ktor.http.HttpMethod
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.headersOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class RemoteYearFortuneDataSourceImplTest {
+ private val json = TodakunJson
+ private val baseUrl = "https://test.example.com/"
+ private val jsonHeaders = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
+
+ private fun buildSut(handler: MockRequestHandler): RemoteYearFortuneDataSourceImpl {
+ val engine = MockEngine(handler)
+ val client = HttpClient(engine) { installTodakunDefaults(json, baseUrl) }
+ return RemoteYearFortuneDataSourceImpl(Lazy { client })
+ }
+
+ @Test
+ fun `postYearFortune을_호출하면_정상_응답이_YearFortune_도메인_모델로_반환된다`() =
+ runTest {
+ // given
+ val body =
+ """{"success":true,"code":"200","message":"ok",""" +
+ """"data":{"id":"f-1","year":2026,"score":85,"title":"t","content":"c",""" +
+ """"fortuneCategories":[{"fortuneCategory":"MONEY","star":3}]}}"""
+ val sut =
+ buildSut { request ->
+ // 메서드/경로가 REST 엔드포인트(POST /api/v1/year-fortunes/{year})와 어긋나도
+ // 통과하지 않도록 요청 자체를 검증한다.
+ assertEquals(HttpMethod.Post, request.method)
+ assertEquals("/api/v1/year-fortunes/2026", request.url.encodedPath)
+ respond(
+ content = body,
+ status = HttpStatusCode.OK,
+ headers = jsonHeaders,
+ )
+ }
+
+ // when
+ val result = sut.postYearFortune(2026)
+
+ // then
+ assertEquals(
+ YearFortune(
+ id = "f-1",
+ year = 2026,
+ score = 85,
+ title = "t",
+ content = "c",
+ categories = listOf(FortuneCategoryStar(FortuneCategory.MONEY, 3)),
+ ),
+ result,
+ )
+ }
+
+ @Test
+ fun `연도별_운세_응답이_401이면_예외가_삼켜지지_않고_그대로_throw된다`() =
+ runTest {
+ // given
+ val sut =
+ buildSut {
+ respond("", HttpStatusCode.Unauthorized)
+ }
+
+ // when
+ val result = runCatching { sut.postYearFortune(2026) }
+
+ // then
+ assertTrue(result.isFailure)
+ }
+
+ @Test
+ fun `getYearFortune을_호출하면_id_기준_GET_요청으로_YearFortune_도메인_모델이_반환된다`() =
+ runTest {
+ // given
+ val body =
+ """{"success":true,"code":"200","message":"ok",""" +
+ """"data":{"id":"f-1","year":2026,"score":85,"title":"t","content":"c",""" +
+ """"fortuneCategories":[{"fortuneCategory":"MONEY","star":3}]}}"""
+ val sut =
+ buildSut { request ->
+ // GET /api/v1/year-fortunes/{id}와 어긋나도 통과하지 않도록 요청 자체를 검증한다.
+ assertEquals(HttpMethod.Get, request.method)
+ assertEquals("/api/v1/year-fortunes/f-1", request.url.encodedPath)
+ respond(
+ content = body,
+ status = HttpStatusCode.OK,
+ headers = jsonHeaders,
+ )
+ }
+
+ // when
+ val result = sut.getYearFortune("f-1")
+
+ // then
+ assertEquals(
+ YearFortune(
+ id = "f-1",
+ year = 2026,
+ score = 85,
+ title = "t",
+ content = "c",
+ categories = listOf(FortuneCategoryStar(FortuneCategory.MONEY, 3)),
+ ),
+ result,
+ )
+ }
+
+ @Test
+ fun `getYearFortune_응답이_404이면_예외가_삼켜지지_않고_그대로_throw된다`() =
+ runTest {
+ // given
+ val sut =
+ buildSut {
+ respond("", HttpStatusCode.NotFound)
+ }
+
+ // when
+ val result = runCatching { sut.getYearFortune("missing-id") }
+
+ // then
+ assertTrue(result.isFailure)
+ }
+}
diff --git a/core/data/src/main/java/com/kikidan/data/datasource/RemoteCompatibilityDataSource.kt b/core/data/src/main/java/com/kikidan/data/datasource/RemoteCompatibilityDataSource.kt
new file mode 100644
index 00000000..a8982194
--- /dev/null
+++ b/core/data/src/main/java/com/kikidan/data/datasource/RemoteCompatibilityDataSource.kt
@@ -0,0 +1,9 @@
+package com.kikidan.data.datasource
+
+import com.kikidan.domain.model.compatibility.Compatibility
+
+interface RemoteCompatibilityDataSource {
+ suspend fun postCompatibility(partnerLinkId: String): Compatibility
+
+ suspend fun getCompatibility(compatibilityId: String): Compatibility
+}
diff --git a/core/data/src/main/java/com/kikidan/data/datasource/RemoteDayFortuneDataSource.kt b/core/data/src/main/java/com/kikidan/data/datasource/RemoteDayFortuneDataSource.kt
new file mode 100644
index 00000000..514e210b
--- /dev/null
+++ b/core/data/src/main/java/com/kikidan/data/datasource/RemoteDayFortuneDataSource.kt
@@ -0,0 +1,14 @@
+package com.kikidan.data.datasource
+
+import com.kikidan.domain.model.dayfortune.DayFortune
+import com.kikidan.domain.model.dayfortune.DayFortunePurpose
+import java.time.LocalDate
+
+interface RemoteDayFortuneDataSource {
+ suspend fun postDayFortunes(
+ purpose: DayFortunePurpose,
+ targetDates: List,
+ ): List
+
+ suspend fun getDayFortune(id: String): DayFortune
+}
diff --git a/core/data/src/main/java/com/kikidan/data/datasource/RemotePartnerSajuDataSource.kt b/core/data/src/main/java/com/kikidan/data/datasource/RemotePartnerSajuDataSource.kt
index 498222b8..3c4df2fe 100644
--- a/core/data/src/main/java/com/kikidan/data/datasource/RemotePartnerSajuDataSource.kt
+++ b/core/data/src/main/java/com/kikidan/data/datasource/RemotePartnerSajuDataSource.kt
@@ -2,12 +2,15 @@ package com.kikidan.data.datasource
import com.kikidan.domain.model.saju.PartnerSaju
import com.kikidan.domain.model.saju.PartnerSajuInput
+import com.kikidan.domain.model.saju.SajuChartDetail
interface RemotePartnerSajuDataSource {
suspend fun getPartnerSajuList(): List
suspend fun getPartnerSaju(linkId: String): PartnerSaju
+ suspend fun getPartnerSajuChartDetail(linkId: String): SajuChartDetail
+
suspend fun deletePartnerSaju(linkId: String)
suspend fun registerPartnerSaju(input: PartnerSajuInput)
diff --git a/core/data/src/main/java/com/kikidan/data/datasource/RemoteYearFortuneDataSource.kt b/core/data/src/main/java/com/kikidan/data/datasource/RemoteYearFortuneDataSource.kt
new file mode 100644
index 00000000..562ea792
--- /dev/null
+++ b/core/data/src/main/java/com/kikidan/data/datasource/RemoteYearFortuneDataSource.kt
@@ -0,0 +1,9 @@
+package com.kikidan.data.datasource
+
+import com.kikidan.domain.model.fortune.YearFortune
+
+interface RemoteYearFortuneDataSource {
+ suspend fun postYearFortune(year: Int): YearFortune
+
+ suspend fun getYearFortune(id: String): YearFortune
+}
diff --git a/core/data/src/main/java/com/kikidan/data/di/RepositoryModule.kt b/core/data/src/main/java/com/kikidan/data/di/RepositoryModule.kt
index d71e94b6..eb70511c 100644
--- a/core/data/src/main/java/com/kikidan/data/di/RepositoryModule.kt
+++ b/core/data/src/main/java/com/kikidan/data/di/RepositoryModule.kt
@@ -2,6 +2,8 @@ package com.kikidan.data.di
import com.kikidan.data.repository.AuthRepositoryImpl
import com.kikidan.data.repository.ChatRepositoryImpl
+import com.kikidan.data.repository.CompatibilityRepositoryImpl
+import com.kikidan.data.repository.DayFortuneRepositoryImpl
import com.kikidan.data.repository.DeviceTokenRepositoryImpl
import com.kikidan.data.repository.FortuneRepositoryImpl
import com.kikidan.data.repository.LuckActionRepositoryImpl
@@ -10,8 +12,11 @@ import com.kikidan.data.repository.PartnerSajuRepositoryImpl
import com.kikidan.data.repository.SajuRepositoryImpl
import com.kikidan.data.repository.TokenRepositoryImpl
import com.kikidan.data.repository.UserRepositoryImpl
+import com.kikidan.data.repository.YearFortuneRepositoryImpl
import com.kikidan.domain.repository.AuthRepository
import com.kikidan.domain.repository.ChatRepository
+import com.kikidan.domain.repository.CompatibilityRepository
+import com.kikidan.domain.repository.DayFortuneRepository
import com.kikidan.domain.repository.DeviceTokenRepository
import com.kikidan.domain.repository.FortuneRepository
import com.kikidan.domain.repository.LuckActionRepository
@@ -20,6 +25,7 @@ import com.kikidan.domain.repository.PartnerSajuRepository
import com.kikidan.domain.repository.SajuRepository
import com.kikidan.domain.repository.TokenRepository
import com.kikidan.domain.repository.UserRepository
+import com.kikidan.domain.repository.YearFortuneRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@@ -37,6 +43,10 @@ abstract class RepositoryModule {
@Singleton
abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
+ @Binds
+ @Singleton
+ abstract fun bindYearFortuneRepository(impl: YearFortuneRepositoryImpl): YearFortuneRepository
+
@Binds
@Singleton
abstract fun bindLuckActionRepository(impl: LuckActionRepositoryImpl): LuckActionRepository
@@ -49,6 +59,10 @@ abstract class RepositoryModule {
@Singleton
abstract fun bindChatRepository(impl: ChatRepositoryImpl): ChatRepository
+ @Binds
+ @Singleton
+ abstract fun bindDayFortuneRepository(impl: DayFortuneRepositoryImpl): DayFortuneRepository
+
@Binds
@Singleton
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
@@ -68,4 +82,8 @@ abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindDeviceTokenRepository(impl: DeviceTokenRepositoryImpl): DeviceTokenRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindCompatibilityRepository(impl: CompatibilityRepositoryImpl): CompatibilityRepository
}
diff --git a/core/data/src/main/java/com/kikidan/data/repository/CompatibilityRepositoryImpl.kt b/core/data/src/main/java/com/kikidan/data/repository/CompatibilityRepositoryImpl.kt
new file mode 100644
index 00000000..5c50216d
--- /dev/null
+++ b/core/data/src/main/java/com/kikidan/data/repository/CompatibilityRepositoryImpl.kt
@@ -0,0 +1,19 @@
+package com.kikidan.data.repository
+
+import com.kikidan.data.datasource.RemoteCompatibilityDataSource
+import com.kikidan.domain.model.compatibility.Compatibility
+import com.kikidan.domain.repository.CompatibilityRepository
+import com.kikidan.domain.util.runCatchingCancellable
+import javax.inject.Inject
+
+class CompatibilityRepositoryImpl
+ @Inject
+ constructor(
+ private val remoteCompatibilityDataSource: RemoteCompatibilityDataSource,
+ ) : CompatibilityRepository {
+ override suspend fun createCompatibility(partnerLinkId: String): Result =
+ runCatchingCancellable { remoteCompatibilityDataSource.postCompatibility(partnerLinkId) }
+
+ override suspend fun getCompatibility(compatibilityId: String): Result =
+ runCatchingCancellable { remoteCompatibilityDataSource.getCompatibility(compatibilityId) }
+ }
diff --git a/core/data/src/main/java/com/kikidan/data/repository/DayFortuneRepositoryImpl.kt b/core/data/src/main/java/com/kikidan/data/repository/DayFortuneRepositoryImpl.kt
new file mode 100644
index 00000000..387d942e
--- /dev/null
+++ b/core/data/src/main/java/com/kikidan/data/repository/DayFortuneRepositoryImpl.kt
@@ -0,0 +1,28 @@
+package com.kikidan.data.repository
+
+import com.kikidan.data.datasource.RemoteDayFortuneDataSource
+import com.kikidan.domain.model.dayfortune.DayFortune
+import com.kikidan.domain.model.dayfortune.DayFortunePurpose
+import com.kikidan.domain.repository.DayFortuneRepository
+import com.kikidan.domain.util.runCatchingCancellable
+import java.time.LocalDate
+import javax.inject.Inject
+
+class DayFortuneRepositoryImpl
+ @Inject
+ constructor(
+ private val remoteDayFortuneDataSource: RemoteDayFortuneDataSource,
+ ) : DayFortuneRepository {
+ override suspend fun createDayFortunes(
+ purpose: DayFortunePurpose,
+ targetDates: List,
+ ): Result> =
+ runCatchingCancellable {
+ remoteDayFortuneDataSource.postDayFortunes(purpose, targetDates)
+ }
+
+ override suspend fun getDayFortune(id: String): Result =
+ runCatchingCancellable {
+ remoteDayFortuneDataSource.getDayFortune(id)
+ }
+ }
diff --git a/core/data/src/main/java/com/kikidan/data/repository/FakePartnerSajuRepository.kt b/core/data/src/main/java/com/kikidan/data/repository/FakePartnerSajuRepository.kt
index aab4360a..0e2eb829 100644
--- a/core/data/src/main/java/com/kikidan/data/repository/FakePartnerSajuRepository.kt
+++ b/core/data/src/main/java/com/kikidan/data/repository/FakePartnerSajuRepository.kt
@@ -1,8 +1,10 @@
package com.kikidan.data.repository
+import com.kikidan.domain.model.saju.CheonGan
import com.kikidan.domain.model.saju.PartnerSaju
import com.kikidan.domain.model.saju.PartnerSajuInput
import com.kikidan.domain.model.saju.RelationshipType
+import com.kikidan.domain.model.saju.SajuChartDetail
import com.kikidan.domain.model.user.Birth
import com.kikidan.domain.model.user.BirthTime
import com.kikidan.domain.model.user.DateType
@@ -30,6 +32,9 @@ class FakePartnerSajuRepository
}
}
+ override suspend fun getPartnerSajuChartDetail(linkId: String): Result =
+ Result.success(MockSajuChartDetail)
+
override suspend fun deletePartnerSaju(linkId: String): Result {
partners = partners.filterNot { it.linkId == linkId }
return Result.success(Unit)
@@ -65,6 +70,13 @@ private val MockRelationshipTypes =
"COLLEAGUE" to RelationshipType(code = "COLLEAGUE", label = "동료"),
)
+private val MockSajuChartDetail =
+ SajuChartDetail(
+ dayMaster = CheonGan.GI,
+ pillars = emptyList(),
+ fiveElements = emptyList(),
+ )
+
private val MockPartnerSajuList =
listOf(
PartnerSaju(
diff --git a/core/data/src/main/java/com/kikidan/data/repository/PartnerSajuRepositoryImpl.kt b/core/data/src/main/java/com/kikidan/data/repository/PartnerSajuRepositoryImpl.kt
index 4c2cffd4..e3a02562 100644
--- a/core/data/src/main/java/com/kikidan/data/repository/PartnerSajuRepositoryImpl.kt
+++ b/core/data/src/main/java/com/kikidan/data/repository/PartnerSajuRepositoryImpl.kt
@@ -3,6 +3,7 @@ package com.kikidan.data.repository
import com.kikidan.data.datasource.RemotePartnerSajuDataSource
import com.kikidan.domain.model.saju.PartnerSaju
import com.kikidan.domain.model.saju.PartnerSajuInput
+import com.kikidan.domain.model.saju.SajuChartDetail
import com.kikidan.domain.repository.PartnerSajuRepository
import com.kikidan.domain.util.runCatchingCancellable
import javax.inject.Inject
@@ -18,6 +19,9 @@ class PartnerSajuRepositoryImpl
override suspend fun getPartnerSaju(linkId: String): Result =
runCatchingCancellable { remotePartnerSajuDataSource.getPartnerSaju(linkId) }
+ override suspend fun getPartnerSajuChartDetail(linkId: String): Result =
+ runCatchingCancellable { remotePartnerSajuDataSource.getPartnerSajuChartDetail(linkId) }
+
override suspend fun deletePartnerSaju(linkId: String): Result =
runCatchingCancellable { remotePartnerSajuDataSource.deletePartnerSaju(linkId) }
diff --git a/core/data/src/main/java/com/kikidan/data/repository/YearFortuneRepositoryImpl.kt b/core/data/src/main/java/com/kikidan/data/repository/YearFortuneRepositoryImpl.kt
new file mode 100644
index 00000000..19c9df4e
--- /dev/null
+++ b/core/data/src/main/java/com/kikidan/data/repository/YearFortuneRepositoryImpl.kt
@@ -0,0 +1,23 @@
+package com.kikidan.data.repository
+
+import com.kikidan.data.datasource.RemoteYearFortuneDataSource
+import com.kikidan.domain.model.fortune.YearFortune
+import com.kikidan.domain.repository.YearFortuneRepository
+import com.kikidan.domain.util.runCatchingCancellable
+import javax.inject.Inject
+
+class YearFortuneRepositoryImpl
+ @Inject
+ constructor(
+ private val remoteFortuneDataSource: RemoteYearFortuneDataSource,
+ ) : YearFortuneRepository {
+ override suspend fun createYearFortune(year: Int): Result =
+ runCatchingCancellable {
+ remoteFortuneDataSource.postYearFortune(year)
+ }
+
+ override suspend fun getYearFortune(id: String): Result =
+ runCatchingCancellable {
+ remoteFortuneDataSource.getYearFortune(id)
+ }
+ }
diff --git a/core/data/src/test/java/com/kikidan/data/fake/FakeRemoteDayFortuneDataSource.kt b/core/data/src/test/java/com/kikidan/data/fake/FakeRemoteDayFortuneDataSource.kt
new file mode 100644
index 00000000..84c78964
--- /dev/null
+++ b/core/data/src/test/java/com/kikidan/data/fake/FakeRemoteDayFortuneDataSource.kt
@@ -0,0 +1,27 @@
+package com.kikidan.data.fake
+
+import com.kikidan.data.datasource.RemoteDayFortuneDataSource
+import com.kikidan.domain.model.dayfortune.DayFortune
+import com.kikidan.domain.model.dayfortune.DayFortunePurpose
+import java.time.LocalDate
+
+class FakeRemoteDayFortuneDataSource : RemoteDayFortuneDataSource {
+ var dayFortunes: List = emptyList()
+ var throwOnPost: Throwable? = null
+
+ var dayFortune: DayFortune? = null
+ var throwOnGet: Throwable? = null
+
+ override suspend fun postDayFortunes(
+ purpose: DayFortunePurpose,
+ targetDates: List,
+ ): List {
+ throwOnPost?.let { throw it }
+ return dayFortunes
+ }
+
+ override suspend fun getDayFortune(id: String): DayFortune {
+ throwOnGet?.let { throw it }
+ return requireNotNull(dayFortune) { "dayFortune이 설정되지 않았습니다." }
+ }
+}
diff --git a/core/data/src/test/java/com/kikidan/data/fake/FakeRemoteYearFortuneDataSource.kt b/core/data/src/test/java/com/kikidan/data/fake/FakeRemoteYearFortuneDataSource.kt
new file mode 100644
index 00000000..097853e9
--- /dev/null
+++ b/core/data/src/test/java/com/kikidan/data/fake/FakeRemoteYearFortuneDataSource.kt
@@ -0,0 +1,30 @@
+package com.kikidan.data.fake
+
+import com.kikidan.data.datasource.RemoteYearFortuneDataSource
+import com.kikidan.domain.model.fortune.FortuneCategory
+import com.kikidan.domain.model.fortune.FortuneCategoryStar
+import com.kikidan.domain.model.fortune.YearFortune
+
+class FakeRemoteYearFortuneDataSource : RemoteYearFortuneDataSource {
+ var yearFortune: YearFortune =
+ YearFortune(
+ id = "f-1",
+ year = 2026,
+ score = 85,
+ title = "title",
+ content = "content",
+ categories = listOf(FortuneCategoryStar(FortuneCategory.MONEY, 3)),
+ )
+ var throwOnPostYearFortune: Throwable? = null
+ var throwOnGetYearFortune: Throwable? = null
+
+ override suspend fun postYearFortune(year: Int): YearFortune {
+ throwOnPostYearFortune?.let { throw it }
+ return yearFortune
+ }
+
+ override suspend fun getYearFortune(id: String): YearFortune {
+ throwOnGetYearFortune?.let { throw it }
+ return yearFortune
+ }
+}
diff --git a/core/data/src/test/java/com/kikidan/data/repository/DayFortuneRepositoryImplTest.kt b/core/data/src/test/java/com/kikidan/data/repository/DayFortuneRepositoryImplTest.kt
new file mode 100644
index 00000000..21043cba
--- /dev/null
+++ b/core/data/src/test/java/com/kikidan/data/repository/DayFortuneRepositoryImplTest.kt
@@ -0,0 +1,113 @@
+package com.kikidan.data.repository
+
+import com.kikidan.data.fake.FakeRemoteDayFortuneDataSource
+import com.kikidan.domain.model.dayfortune.DayFortune
+import com.kikidan.domain.model.dayfortune.DayFortunePurpose
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import java.io.IOException
+import java.time.LocalDate
+
+class DayFortuneRepositoryImplTest {
+ private lateinit var fakeRemoteDayFortuneDataSource: FakeRemoteDayFortuneDataSource
+ private lateinit var sut: DayFortuneRepositoryImpl
+
+ private val purpose = DayFortunePurpose.TRAVEL
+ private val targetDates = listOf(LocalDate.of(2026, 8, 10))
+
+ @Before
+ fun setUp() {
+ fakeRemoteDayFortuneDataSource = FakeRemoteDayFortuneDataSource()
+ sut = DayFortuneRepositoryImpl(fakeRemoteDayFortuneDataSource)
+ }
+
+ @Test
+ fun `DataSource가_정상_반환하면_Result_success로_동일한_값이_반환된다`() =
+ runTest {
+ // given
+ val expected =
+ listOf(
+ DayFortune(
+ id = "id-1",
+ purpose = purpose,
+ targetDate = targetDates.first(),
+ score = 85,
+ title = "title",
+ content = "content",
+ categoryStars = emptyList(),
+ ),
+ )
+ fakeRemoteDayFortuneDataSource.dayFortunes = expected
+
+ // when
+ val result = sut.createDayFortunes(purpose, targetDates)
+
+ // then
+ assertEquals(expected, result.getOrNull())
+ }
+
+ @Test
+ fun `DataSource가_IOException을_throw하면_Result_failure로_반환되고_예외가_누수되지_않는다`() =
+ runTest {
+ // given
+ fakeRemoteDayFortuneDataSource.throwOnPost = IOException("network error")
+
+ // when
+ val result = sut.createDayFortunes(purpose, targetDates)
+
+ // then
+ assertTrue(result.isFailure)
+ assertTrue(result.exceptionOrNull() is IOException)
+ }
+
+ @Test(expected = CancellationException::class)
+ fun `DataSource가_CancellationException을_throw하면_Result로_감싸지지_않고_그대로_전파된다`() =
+ runTest {
+ // given
+ fakeRemoteDayFortuneDataSource.throwOnPost = CancellationException("cancelled")
+
+ // when
+ sut.createDayFortunes(purpose, targetDates)
+ }
+
+ @Test
+ fun `getDayFortune_DataSource가_정상_반환하면_Result_success로_동일한_값이_반환된다`() =
+ runTest {
+ // given
+ val expected =
+ DayFortune(
+ id = "id-1",
+ purpose = purpose,
+ targetDate = targetDates.first(),
+ score = 85,
+ title = "title",
+ content = "content",
+ categoryStars = emptyList(),
+ )
+ fakeRemoteDayFortuneDataSource.dayFortune = expected
+
+ // when
+ val result = sut.getDayFortune("id-1")
+
+ // then
+ assertEquals(expected, result.getOrNull())
+ }
+
+ @Test
+ fun `getDayFortune_DataSource가_IOException을_throw하면_Result_failure로_반환되고_예외가_누수되지_않는다`() =
+ runTest {
+ // given
+ fakeRemoteDayFortuneDataSource.throwOnGet = IOException("network error")
+
+ // when
+ val result = sut.getDayFortune("id-1")
+
+ // then
+ assertTrue(result.isFailure)
+ assertTrue(result.exceptionOrNull() is IOException)
+ }
+}
diff --git a/core/data/src/test/java/com/kikidan/data/repository/YearFortuneRepositoryImplTest.kt b/core/data/src/test/java/com/kikidan/data/repository/YearFortuneRepositoryImplTest.kt
new file mode 100644
index 00000000..b48a16d4
--- /dev/null
+++ b/core/data/src/test/java/com/kikidan/data/repository/YearFortuneRepositoryImplTest.kt
@@ -0,0 +1,116 @@
+package com.kikidan.data.repository
+
+import com.kikidan.data.fake.FakeRemoteYearFortuneDataSource
+import com.kikidan.domain.model.fortune.FortuneCategory
+import com.kikidan.domain.model.fortune.FortuneCategoryStar
+import com.kikidan.domain.model.fortune.YearFortune
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import java.io.IOException
+
+class YearFortuneRepositoryImplTest {
+ private lateinit var fakeRemoteFortuneDataSource: FakeRemoteYearFortuneDataSource
+ private lateinit var sut: YearFortuneRepositoryImpl
+
+ @Before
+ fun setUp() {
+ fakeRemoteFortuneDataSource = FakeRemoteYearFortuneDataSource()
+ sut = YearFortuneRepositoryImpl(fakeRemoteFortuneDataSource)
+ }
+
+ @Test
+ fun `createYearFortune이_성공하면_DataSource의_YearFortune이_그대로_Result_success로_반환된다`() =
+ runTest {
+ // given
+ val expected =
+ YearFortune(
+ id = "f-2",
+ year = 2027,
+ score = 90,
+ title = "title-2",
+ content = "content-2",
+ categories = listOf(FortuneCategoryStar(FortuneCategory.HEALTH, 5)),
+ )
+ fakeRemoteFortuneDataSource.yearFortune = expected
+
+ // when
+ val result = sut.createYearFortune(2027)
+
+ // then
+ assertEquals(expected, result.getOrNull())
+ }
+
+ @Test
+ fun `DataSource의_postYearFortune이_예외를_throw하면_Result_failure로_반환되고_예외가_누수되지_않는다`() =
+ runTest {
+ // given
+ fakeRemoteFortuneDataSource.throwOnPostYearFortune = IOException("network error")
+
+ // when
+ val result = sut.createYearFortune(2026)
+
+ // then
+ assertTrue(result.isFailure)
+ assertTrue(result.exceptionOrNull() is IOException)
+ }
+
+ @Test(expected = CancellationException::class)
+ fun `DataSource의_postYearFortune이_CancellationException을_throw하면_Result로_감싸지지_않고_그대로_전파된다`() =
+ runTest {
+ // given
+ fakeRemoteFortuneDataSource.throwOnPostYearFortune = CancellationException("cancelled")
+
+ // when
+ sut.createYearFortune(2026)
+ }
+
+ @Test
+ fun `getYearFortune이_성공하면_DataSource의_YearFortune이_그대로_Result_success로_반환된다`() =
+ runTest {
+ // given
+ val expected =
+ YearFortune(
+ id = "f-2",
+ year = 2027,
+ score = 90,
+ title = "title-2",
+ content = "content-2",
+ categories = listOf(FortuneCategoryStar(FortuneCategory.HEALTH, 5)),
+ )
+ fakeRemoteFortuneDataSource.yearFortune = expected
+
+ // when
+ val result = sut.getYearFortune("f-2")
+
+ // then
+ assertEquals(expected, result.getOrNull())
+ }
+
+ @Test
+ fun `DataSource의_getYearFortune이_예외를_throw하면_Result_failure로_반환되고_예외가_누수되지_않는다`() =
+ runTest {
+ // given
+ fakeRemoteFortuneDataSource.throwOnGetYearFortune = IOException("network error")
+
+ // when
+ val result = sut.getYearFortune("f-1")
+
+ // then
+ assertTrue(result.isFailure)
+ assertTrue(result.exceptionOrNull() is IOException)
+ }
+
+ @Test(expected = CancellationException::class)
+ fun `DataSource의_getYearFortune이_CancellationException을_throw하면_Result로_감싸지지_않고_그대로_전파된다`() =
+ runTest {
+ // given
+ fakeRemoteFortuneDataSource.throwOnGetYearFortune = CancellationException("cancelled")
+
+ // when
+ sut.getYearFortune("f-1")
+ }
+}
diff --git a/core/designsystem/src/main/java/com/kikidan/designsystem/component/wheelpicker/TodakunWheelPicker.kt b/core/designsystem/src/main/java/com/kikidan/designsystem/component/wheelpicker/TodakunWheelPicker.kt
index d2cdeda1..2a04ea40 100644
--- a/core/designsystem/src/main/java/com/kikidan/designsystem/component/wheelpicker/TodakunWheelPicker.kt
+++ b/core/designsystem/src/main/java/com/kikidan/designsystem/component/wheelpicker/TodakunWheelPicker.kt
@@ -44,6 +44,7 @@ fun TodakunWheelPicker(
modifier: Modifier = Modifier,
visibleCount: Int = 5,
directInputEnabled: Boolean = false,
+ saveButtonLabel: String = stringResource(R.string.wheel_picker_save),
onColumnDirectInputCommit: (columnIndex: Int, rawDigits: String) -> Unit = { _, _ -> },
) {
val focusManager = LocalFocusManager.current
@@ -62,6 +63,7 @@ fun TodakunWheelPicker(
) {
WheelPickerHeader(
title = title,
+ actionLabel = saveButtonLabel,
onSaveClick = onSaveClick,
)
@@ -124,6 +126,7 @@ private fun RowScope.WheelPickerColumnSpacer(
@Composable
private fun WheelPickerHeader(
title: String,
+ actionLabel: String,
onSaveClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -134,7 +137,7 @@ private fun WheelPickerHeader(
) {
Text(text = title, style = TodakunTypography.heading4Bold, color = TodakunColor.black)
Text(
- text = stringResource(R.string.wheel_picker_save),
+ text = actionLabel,
style = TodakunTypography.body2SemiBold,
color = TodakunColor.primary600,
modifier =
diff --git a/core/designsystem/src/main/java/com/kikidan/designsystem/component/wheelpicker/WheelPickerBottomSheet.kt b/core/designsystem/src/main/java/com/kikidan/designsystem/component/wheelpicker/WheelPickerBottomSheet.kt
index 5be1eddd..efee3f83 100644
--- a/core/designsystem/src/main/java/com/kikidan/designsystem/component/wheelpicker/WheelPickerBottomSheet.kt
+++ b/core/designsystem/src/main/java/com/kikidan/designsystem/component/wheelpicker/WheelPickerBottomSheet.kt
@@ -28,7 +28,7 @@ import com.kikidan.designsystem.theme.TodakunColor
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-internal fun WheelPickerBottomSheet(
+fun WheelPickerBottomSheet(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
bottomSpacing: Dp = WheelPickerBottomSheetDefaults.BottomSpacing,
diff --git a/core/designsystem/src/main/res/drawable/ic_event_export.xml b/core/designsystem/src/main/res/drawable/ic_event_export.xml
new file mode 100644
index 00000000..2b635353
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_event_export.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_share.xml b/core/designsystem/src/main/res/drawable/ic_share.xml
new file mode 100644
index 00000000..e2b7e7a6
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_share.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_star_empty.xml b/core/designsystem/src/main/res/drawable/ic_star_empty.xml
new file mode 100644
index 00000000..d8ffd8e2
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_star_empty.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_star_fill.xml b/core/designsystem/src/main/res/drawable/ic_star_fill.xml
new file mode 100644
index 00000000..3959268f
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_star_fill.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_star_filled.xml b/core/designsystem/src/main/res/drawable/ic_star_filled.xml
new file mode 100644
index 00000000..1c5d995a
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_star_filled.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml
index 14ccf1f9..5271b32e 100644
--- a/core/designsystem/src/main/res/values/strings.xml
+++ b/core/designsystem/src/main/res/values/strings.xml
@@ -427,4 +427,68 @@
환영해요, 이제 시작해볼까요?
환영해요!\n가입이 완료되었어요.
토닥운이 사주 기반으로\n운세를 분석하고 있어요...
+
+
+ 무슨 날을 정하시나요?
+ 무슨 날을 정하시나요?
+ 남성
+ 여성
+ 계약・이사
+ 개업
+ 여행
+ 고백・소개팅
+ 시험・면접
+ 후보 날짜
+ (%1$d개까지 가능)
+ 날짜를 선택해주세요
+ 무료로 택일 보기
+ 날짜 삭제
+
+
+ 후보 날짜 선택
+ 닫기
+ %1$d개까지 날짜 선택 가능해요
+ 초기화
+ 선택 완료
+ 후보 날짜는 %1$d개까지만 선택 가능합니다.
+ 오늘
+ 일
+ 월
+ 화
+ 수
+ 목
+ 금
+ 토
+
+
+ 택일 운세를 불러오지 못했어요. 다시 시도해주세요.
+
+
+ 택일 운세 결과
+ "선택하신 후보 일자 중\n최고의 기운을 찾아봤어요"
+ 최고의 기운
+ 시간대는 언제가 좋아?
+ 운세 점수
+ 요약 분석
+ 토닥이에게 더 물어보기
+ 공유
+ 캘린더에 저장
+ 캘린더 앱을 찾을 수 없어요.
+ 결과를 불러오지 못했어요. 다시 시도해주세요.
+
+
+ 운세 공유하기
+ 카카오톡 공유
+ URL 복사
+ 취소
+ %1$s\n토닥운에서 확인해보세요.
+ 카카오톡으로 공유할 수 없어요. 카카오톡 설치 여부를 확인해주세요.
+ 링크를 복사했어요.
+
+
+ 관계운
+ 애정운
+ 성취운
+ 재물운
+ 건강운
diff --git a/core/domain/src/main/java/com/kikidan/domain/model/compatibility/Compatibility.kt b/core/domain/src/main/java/com/kikidan/domain/model/compatibility/Compatibility.kt
new file mode 100644
index 00000000..2953ba72
--- /dev/null
+++ b/core/domain/src/main/java/com/kikidan/domain/model/compatibility/Compatibility.kt
@@ -0,0 +1,22 @@
+package com.kikidan.domain.model.compatibility
+
+import com.kikidan.domain.model.saju.Ohaeng
+import com.kikidan.domain.model.saju.RelationshipType
+
+data class Compatibility(
+ val id: String,
+ val partnerName: String,
+ val relationshipType: RelationshipType,
+ val score: Int,
+ val headline: String,
+ val subheadline: String,
+ val summary: String,
+ val totalAnalysis: String,
+ val analysisBasis: String,
+ val ohaengs: List,
+)
+
+data class CompatibilityOhaeng(
+ val ohaeng: Ohaeng,
+ val percentage: Int,
+)
diff --git a/core/domain/src/main/java/com/kikidan/domain/model/dayfortune/DayFortune.kt b/core/domain/src/main/java/com/kikidan/domain/model/dayfortune/DayFortune.kt
new file mode 100644
index 00000000..5f76756a
--- /dev/null
+++ b/core/domain/src/main/java/com/kikidan/domain/model/dayfortune/DayFortune.kt
@@ -0,0 +1,22 @@
+package com.kikidan.domain.model.dayfortune
+
+import com.kikidan.domain.model.fortune.FortuneCategoryStar
+import java.time.LocalDate
+
+data class DayFortune(
+ val id: String,
+ val purpose: DayFortunePurpose,
+ val targetDate: LocalDate,
+ val score: Int,
+ val title: String,
+ val content: String,
+ val categoryStars: List,
+)
+
+enum class DayFortunePurpose {
+ CONTRACT_MOVING,
+ BUSINESS_OPENING,
+ TRAVEL,
+ CONFESSION_DATING,
+ EXAM_INTERVIEW,
+}
diff --git a/core/domain/src/main/java/com/kikidan/domain/model/fortune/FortuneCategoryStar.kt b/core/domain/src/main/java/com/kikidan/domain/model/fortune/FortuneCategoryStar.kt
new file mode 100644
index 00000000..26ee1e64
--- /dev/null
+++ b/core/domain/src/main/java/com/kikidan/domain/model/fortune/FortuneCategoryStar.kt
@@ -0,0 +1,6 @@
+package com.kikidan.domain.model.fortune
+
+data class FortuneCategoryStar(
+ val category: FortuneCategory,
+ val star: Int,
+)
diff --git a/core/domain/src/main/java/com/kikidan/domain/model/fortune/YearFortune.kt b/core/domain/src/main/java/com/kikidan/domain/model/fortune/YearFortune.kt
new file mode 100644
index 00000000..664fb1dd
--- /dev/null
+++ b/core/domain/src/main/java/com/kikidan/domain/model/fortune/YearFortune.kt
@@ -0,0 +1,10 @@
+package com.kikidan.domain.model.fortune
+
+data class YearFortune(
+ val id: String,
+ val year: Int,
+ val score: Int,
+ val title: String,
+ val content: String,
+ val categories: List,
+)
diff --git a/core/domain/src/main/java/com/kikidan/domain/model/saju/TwelveSinsal.kt b/core/domain/src/main/java/com/kikidan/domain/model/saju/TwelveSinsal.kt
index 86a50cf4..99b3f77d 100644
--- a/core/domain/src/main/java/com/kikidan/domain/model/saju/TwelveSinsal.kt
+++ b/core/domain/src/main/java/com/kikidan/domain/model/saju/TwelveSinsal.kt
@@ -10,7 +10,7 @@ enum class TwelveSinsal {
MANGSINSAL,
JANGSEONGSAL,
BANANSAL,
- YEOKMASAL,
+ YEONGMASAL,
YUKHAESAL,
HWAGAESAL,
}
diff --git a/core/domain/src/main/java/com/kikidan/domain/model/saju/TwelveUnseong.kt b/core/domain/src/main/java/com/kikidan/domain/model/saju/TwelveUnseong.kt
index 50e569f3..34111048 100644
--- a/core/domain/src/main/java/com/kikidan/domain/model/saju/TwelveUnseong.kt
+++ b/core/domain/src/main/java/com/kikidan/domain/model/saju/TwelveUnseong.kt
@@ -2,7 +2,7 @@ package com.kikidan.domain.model.saju
enum class TwelveUnseong {
JANGSAENG,
- MOGYOK,
+ MOKYOK,
GWANDAE,
GEONROK,
JEWANG,
diff --git a/core/domain/src/main/java/com/kikidan/domain/repository/CompatibilityRepository.kt b/core/domain/src/main/java/com/kikidan/domain/repository/CompatibilityRepository.kt
new file mode 100644
index 00000000..ecc2c3e0
--- /dev/null
+++ b/core/domain/src/main/java/com/kikidan/domain/repository/CompatibilityRepository.kt
@@ -0,0 +1,9 @@
+package com.kikidan.domain.repository
+
+import com.kikidan.domain.model.compatibility.Compatibility
+
+interface CompatibilityRepository {
+ suspend fun createCompatibility(partnerLinkId: String): Result
+
+ suspend fun getCompatibility(compatibilityId: String): Result
+}
diff --git a/core/domain/src/main/java/com/kikidan/domain/repository/DayFortuneRepository.kt b/core/domain/src/main/java/com/kikidan/domain/repository/DayFortuneRepository.kt
new file mode 100644
index 00000000..bb107dbb
--- /dev/null
+++ b/core/domain/src/main/java/com/kikidan/domain/repository/DayFortuneRepository.kt
@@ -0,0 +1,14 @@
+package com.kikidan.domain.repository
+
+import com.kikidan.domain.model.dayfortune.DayFortune
+import com.kikidan.domain.model.dayfortune.DayFortunePurpose
+import java.time.LocalDate
+
+interface DayFortuneRepository {
+ suspend fun createDayFortunes(
+ purpose: DayFortunePurpose,
+ targetDates: List,
+ ): Result>
+
+ suspend fun getDayFortune(id: String): Result
+}
diff --git a/core/domain/src/main/java/com/kikidan/domain/repository/PartnerSajuRepository.kt b/core/domain/src/main/java/com/kikidan/domain/repository/PartnerSajuRepository.kt
index cb0d7d24..3c14bd52 100644
--- a/core/domain/src/main/java/com/kikidan/domain/repository/PartnerSajuRepository.kt
+++ b/core/domain/src/main/java/com/kikidan/domain/repository/PartnerSajuRepository.kt
@@ -2,12 +2,15 @@ package com.kikidan.domain.repository
import com.kikidan.domain.model.saju.PartnerSaju
import com.kikidan.domain.model.saju.PartnerSajuInput
+import com.kikidan.domain.model.saju.SajuChartDetail
interface PartnerSajuRepository {
suspend fun getPartnerSajuList(): Result>
suspend fun getPartnerSaju(linkId: String): Result
+ suspend fun getPartnerSajuChartDetail(linkId: String): Result
+
suspend fun deletePartnerSaju(linkId: String): Result
suspend fun registerPartnerSaju(input: PartnerSajuInput): Result
diff --git a/core/domain/src/main/java/com/kikidan/domain/repository/YearFortuneRepository.kt b/core/domain/src/main/java/com/kikidan/domain/repository/YearFortuneRepository.kt
new file mode 100644
index 00000000..ba396924
--- /dev/null
+++ b/core/domain/src/main/java/com/kikidan/domain/repository/YearFortuneRepository.kt
@@ -0,0 +1,9 @@
+package com.kikidan.domain.repository
+
+import com.kikidan.domain.model.fortune.YearFortune
+
+interface YearFortuneRepository {
+ suspend fun createYearFortune(year: Int): Result
+
+ suspend fun getYearFortune(id: String): Result
+}
diff --git a/core/domain/src/main/java/com/kikidan/domain/usecase/CreateCompatibilityUseCase.kt b/core/domain/src/main/java/com/kikidan/domain/usecase/CreateCompatibilityUseCase.kt
new file mode 100644
index 00000000..9a268c87
--- /dev/null
+++ b/core/domain/src/main/java/com/kikidan/domain/usecase/CreateCompatibilityUseCase.kt
@@ -0,0 +1,14 @@
+package com.kikidan.domain.usecase
+
+import com.kikidan.domain.model.compatibility.Compatibility
+import com.kikidan.domain.repository.CompatibilityRepository
+import javax.inject.Inject
+
+class CreateCompatibilityUseCase
+ @Inject
+ constructor(
+ private val compatibilityRepository: CompatibilityRepository,
+ ) {
+ suspend operator fun invoke(partnerLinkId: String): Result =
+ compatibilityRepository.createCompatibility(partnerLinkId)
+ }
diff --git a/core/domain/src/main/java/com/kikidan/domain/usecase/CreateDayFortunesUseCase.kt b/core/domain/src/main/java/com/kikidan/domain/usecase/CreateDayFortunesUseCase.kt
new file mode 100644
index 00000000..3823d3f1
--- /dev/null
+++ b/core/domain/src/main/java/com/kikidan/domain/usecase/CreateDayFortunesUseCase.kt
@@ -0,0 +1,35 @@
+package com.kikidan.domain.usecase
+
+import com.kikidan.domain.model.dayfortune.DayFortune
+import com.kikidan.domain.model.dayfortune.DayFortunePurpose
+import com.kikidan.domain.repository.DayFortuneRepository
+import java.time.LocalDate
+import javax.inject.Inject
+
+object DateFortuneDefaults {
+ const val MAX_TARGET_DATES = 5
+}
+
+class CreateDayFortunesUseCase
+ @Inject
+ constructor(
+ private val dayFortuneRepository: DayFortuneRepository,
+ ) {
+ suspend operator fun invoke(
+ purpose: DayFortunePurpose,
+ targetDates: List,
+ ): Result> {
+ if (targetDates.isEmpty() || targetDates.size > DateFortuneDefaults.MAX_TARGET_DATES) {
+ return Result.failure(
+ IllegalArgumentException(
+ "targetDates 개수는 1~${DateFortuneDefaults.MAX_TARGET_DATES}개여야 합니다. size=${targetDates.size}",
+ ),
+ )
+ }
+ val today = LocalDate.now()
+ if (targetDates.any { it.isBefore(today) }) {
+ return Result.failure(IllegalArgumentException("과거 날짜는 선택할 수 없습니다."))
+ }
+ return dayFortuneRepository.createDayFortunes(purpose, targetDates)
+ }
+ }
diff --git a/core/domain/src/main/java/com/kikidan/domain/usecase/CreateYearFortuneUseCase.kt b/core/domain/src/main/java/com/kikidan/domain/usecase/CreateYearFortuneUseCase.kt
new file mode 100644
index 00000000..7b16ec3c
--- /dev/null
+++ b/core/domain/src/main/java/com/kikidan/domain/usecase/CreateYearFortuneUseCase.kt
@@ -0,0 +1,13 @@
+package com.kikidan.domain.usecase
+
+import com.kikidan.domain.model.fortune.YearFortune
+import com.kikidan.domain.repository.YearFortuneRepository
+import javax.inject.Inject
+
+class CreateYearFortuneUseCase
+ @Inject
+ constructor(
+ private val yearFortuneRepository: YearFortuneRepository,
+ ) {
+ suspend operator fun invoke(year: Int): Result = yearFortuneRepository.createYearFortune(year)
+ }
diff --git a/core/domain/src/main/java/com/kikidan/domain/usecase/GetCompatibilityUseCase.kt b/core/domain/src/main/java/com/kikidan/domain/usecase/GetCompatibilityUseCase.kt
new file mode 100644
index 00000000..6999a1c3
--- /dev/null
+++ b/core/domain/src/main/java/com/kikidan/domain/usecase/GetCompatibilityUseCase.kt
@@ -0,0 +1,14 @@
+package com.kikidan.domain.usecase
+
+import com.kikidan.domain.model.compatibility.Compatibility
+import com.kikidan.domain.repository.CompatibilityRepository
+import javax.inject.Inject
+
+class GetCompatibilityUseCase
+ @Inject
+ constructor(
+ private val compatibilityRepository: CompatibilityRepository,
+ ) {
+ suspend operator fun invoke(compatibilityId: String): Result =
+ compatibilityRepository.getCompatibility(compatibilityId)
+ }
diff --git a/core/domain/src/main/java/com/kikidan/domain/usecase/GetDayFortuneUseCase.kt b/core/domain/src/main/java/com/kikidan/domain/usecase/GetDayFortuneUseCase.kt
new file mode 100644
index 00000000..ad0c4f50
--- /dev/null
+++ b/core/domain/src/main/java/com/kikidan/domain/usecase/GetDayFortuneUseCase.kt
@@ -0,0 +1,13 @@
+package com.kikidan.domain.usecase
+
+import com.kikidan.domain.model.dayfortune.DayFortune
+import com.kikidan.domain.repository.DayFortuneRepository
+import javax.inject.Inject
+
+class GetDayFortuneUseCase
+ @Inject
+ constructor(
+ private val dayFortuneRepository: DayFortuneRepository,
+ ) {
+ suspend operator fun invoke(id: String): Result = dayFortuneRepository.getDayFortune(id)
+ }
diff --git a/core/domain/src/main/java/com/kikidan/domain/usecase/GetYearFortuneUseCase.kt b/core/domain/src/main/java/com/kikidan/domain/usecase/GetYearFortuneUseCase.kt
new file mode 100644
index 00000000..c34dd6bf
--- /dev/null
+++ b/core/domain/src/main/java/com/kikidan/domain/usecase/GetYearFortuneUseCase.kt
@@ -0,0 +1,13 @@
+package com.kikidan.domain.usecase
+
+import com.kikidan.domain.model.fortune.YearFortune
+import com.kikidan.domain.repository.YearFortuneRepository
+import javax.inject.Inject
+
+class GetYearFortuneUseCase
+ @Inject
+ constructor(
+ private val yearFortuneRepository: YearFortuneRepository,
+ ) {
+ suspend operator fun invoke(id: String): Result = yearFortuneRepository.getYearFortune(id)
+ }
diff --git a/core/domain/src/main/java/com/kikidan/domain/usecase/saju/GetPartnerMansaeryeokDetailUseCase.kt b/core/domain/src/main/java/com/kikidan/domain/usecase/saju/GetPartnerMansaeryeokDetailUseCase.kt
new file mode 100644
index 00000000..fa12547a
--- /dev/null
+++ b/core/domain/src/main/java/com/kikidan/domain/usecase/saju/GetPartnerMansaeryeokDetailUseCase.kt
@@ -0,0 +1,29 @@
+package com.kikidan.domain.usecase.saju
+
+import com.kikidan.domain.model.saju.PartnerSaju
+import com.kikidan.domain.model.saju.SajuChartDetail
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import javax.inject.Inject
+
+data class PartnerMansaeryeokDetail(
+ val partner: PartnerSaju,
+ val chart: SajuChartDetail,
+)
+
+class GetPartnerMansaeryeokDetailUseCase
+ @Inject
+ constructor(
+ private val getPartnerSajuUseCase: GetPartnerSajuUseCase,
+ private val getPartnerSajuChartDetailUseCase: GetPartnerSajuChartDetailUseCase,
+ ) {
+ suspend operator fun invoke(linkId: String): Result =
+ coroutineScope {
+ val partnerDeferred = async { getPartnerSajuUseCase(linkId) }
+ val chartDeferred = async { getPartnerSajuChartDetailUseCase(linkId) }
+
+ val partner = partnerDeferred.await().getOrElse { return@coroutineScope Result.failure(it) }
+ val chart = chartDeferred.await().getOrElse { return@coroutineScope Result.failure(it) }
+ Result.success(PartnerMansaeryeokDetail(partner, chart))
+ }
+ }
diff --git a/core/domain/src/main/java/com/kikidan/domain/usecase/saju/GetPartnerSajuChartDetailUseCase.kt b/core/domain/src/main/java/com/kikidan/domain/usecase/saju/GetPartnerSajuChartDetailUseCase.kt
new file mode 100644
index 00000000..c7fe9ac9
--- /dev/null
+++ b/core/domain/src/main/java/com/kikidan/domain/usecase/saju/GetPartnerSajuChartDetailUseCase.kt
@@ -0,0 +1,14 @@
+package com.kikidan.domain.usecase.saju
+
+import com.kikidan.domain.model.saju.SajuChartDetail
+import com.kikidan.domain.repository.PartnerSajuRepository
+import javax.inject.Inject
+
+class GetPartnerSajuChartDetailUseCase
+ @Inject
+ constructor(
+ private val partnerSajuRepository: PartnerSajuRepository,
+ ) {
+ suspend operator fun invoke(linkId: String): Result =
+ partnerSajuRepository.getPartnerSajuChartDetail(linkId)
+ }
diff --git a/core/domain/src/main/java/com/kikidan/domain/util/YearGanjiFormatter.kt b/core/domain/src/main/java/com/kikidan/domain/util/YearGanjiFormatter.kt
new file mode 100644
index 00000000..a274c719
--- /dev/null
+++ b/core/domain/src/main/java/com/kikidan/domain/util/YearGanjiFormatter.kt
@@ -0,0 +1,38 @@
+package com.kikidan.domain.util
+
+// 갑자년(甲子年) 기준 AD 4년부터 60년 주기로 순환한다.
+private val GAN =
+ listOf(
+ "갑" to "甲",
+ "을" to "乙",
+ "병" to "丙",
+ "정" to "丁",
+ "무" to "戊",
+ "기" to "己",
+ "경" to "庚",
+ "신" to "辛",
+ "임" to "壬",
+ "계" to "癸",
+ )
+private val JI =
+ listOf(
+ "자" to "子",
+ "축" to "丑",
+ "인" to "寅",
+ "묘" to "卯",
+ "진" to "辰",
+ "사" to "巳",
+ "오" to "午",
+ "미" to "未",
+ "신" to "申",
+ "유" to "酉",
+ "술" to "戌",
+ "해" to "亥",
+ )
+
+fun yearToGanji(year: Int): String {
+ val index = ((year - 4) % 60 + 60) % 60
+ val (ganKr, ganHanja) = GAN[index % 10]
+ val (jiKr, jiHanja) = JI[index % 12]
+ return "$ganKr${jiKr}년($ganHanja${jiHanja}年)"
+}
diff --git a/core/domain/src/test/java/com/kikidan/domain/fake/FakeDayFortuneRepository.kt b/core/domain/src/test/java/com/kikidan/domain/fake/FakeDayFortuneRepository.kt
new file mode 100644
index 00000000..c8881a66
--- /dev/null
+++ b/core/domain/src/test/java/com/kikidan/domain/fake/FakeDayFortuneRepository.kt
@@ -0,0 +1,35 @@
+package com.kikidan.domain.fake
+
+import com.kikidan.domain.model.dayfortune.DayFortune
+import com.kikidan.domain.model.dayfortune.DayFortunePurpose
+import com.kikidan.domain.repository.DayFortuneRepository
+import java.time.LocalDate
+
+class FakeDayFortuneRepository : DayFortuneRepository {
+ var result: Result> = Result.success(emptyList())
+ var callCount: Int = 0
+ private set
+ var lastPurpose: DayFortunePurpose? = null
+ private set
+ var lastTargetDates: List? = null
+ private set
+
+ var getResult: Result = Result.failure(IllegalStateException("설정되지 않았습니다."))
+ var lastGetId: String? = null
+ private set
+
+ override suspend fun createDayFortunes(
+ purpose: DayFortunePurpose,
+ targetDates: List,
+ ): Result> {
+ callCount++
+ lastPurpose = purpose
+ lastTargetDates = targetDates
+ return result
+ }
+
+ override suspend fun getDayFortune(id: String): Result {
+ lastGetId = id
+ return getResult
+ }
+}
diff --git a/core/domain/src/test/java/com/kikidan/domain/usecase/CreateDayFortunesUseCaseTest.kt b/core/domain/src/test/java/com/kikidan/domain/usecase/CreateDayFortunesUseCaseTest.kt
new file mode 100644
index 00000000..9307e2e2
--- /dev/null
+++ b/core/domain/src/test/java/com/kikidan/domain/usecase/CreateDayFortunesUseCaseTest.kt
@@ -0,0 +1,139 @@
+package com.kikidan.domain.usecase
+
+import com.kikidan.domain.fake.FakeDayFortuneRepository
+import com.kikidan.domain.model.dayfortune.DayFortune
+import com.kikidan.domain.model.dayfortune.DayFortunePurpose
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import java.time.LocalDate
+
+class CreateDayFortunesUseCaseTest {
+ private lateinit var fakeRepository: FakeDayFortuneRepository
+ private lateinit var sut: CreateDayFortunesUseCase
+
+ private val purpose = DayFortunePurpose.TRAVEL
+
+ @Before
+ fun setUp() {
+ fakeRepository = FakeDayFortuneRepository()
+ sut = CreateDayFortunesUseCase(fakeRepository)
+ }
+
+ @Test
+ fun `날짜가_0개면_실패하고_Repository를_호출하지_않는다`() =
+ runTest {
+ // when
+ val result = sut(purpose, emptyList())
+
+ // then
+ assertTrue(result.isFailure)
+ assertEquals(0, fakeRepository.callCount)
+ }
+
+ @Test
+ fun `날짜가_상한을_초과하면_실패하고_Repository를_호출하지_않는다`() =
+ runTest {
+ // given
+ val today = LocalDate.now()
+ val sixDates = (0 until DateFortuneDefaults.MAX_TARGET_DATES + 1).map { today.plusDays(it.toLong()) }
+
+ // when
+ val result = sut(purpose, sixDates)
+
+ // then
+ assertTrue(result.isFailure)
+ assertEquals(0, fakeRepository.callCount)
+ }
+
+ @Test
+ fun `과거_날짜가_포함되면_실패하고_Repository를_호출하지_않는다`() =
+ runTest {
+ // given
+ val pastDate = LocalDate.now().minusDays(1)
+
+ // when
+ val result = sut(purpose, listOf(pastDate))
+
+ // then
+ assertTrue(result.isFailure)
+ assertEquals(0, fakeRepository.callCount)
+ }
+
+ @Test
+ fun `오늘_날짜는_통과한다`() =
+ runTest {
+ // given
+ val today = LocalDate.now()
+ fakeRepository.result = Result.success(emptyList())
+
+ // when
+ val result = sut(purpose, listOf(today))
+
+ // then
+ assertTrue(result.isSuccess)
+ assertEquals(1, fakeRepository.callCount)
+ }
+
+ @Test
+ fun `날짜가_정확히_5개면_통과한다`() =
+ runTest {
+ // given
+ val today = LocalDate.now()
+ val fiveDates = (0 until DateFortuneDefaults.MAX_TARGET_DATES).map { today.plusDays(it.toLong()) }
+ fakeRepository.result = Result.success(emptyList())
+
+ // when
+ val result = sut(purpose, fiveDates)
+
+ // then
+ assertTrue(result.isSuccess)
+ assertEquals(1, fakeRepository.callCount)
+ }
+
+ @Test
+ fun `유효한_입력이면_Repository의_반환값이_그대로_전달된다`() =
+ runTest {
+ // given
+ val today = LocalDate.now()
+ val expected =
+ listOf(
+ DayFortune(
+ id = "id-1",
+ purpose = purpose,
+ targetDate = today,
+ score = 85,
+ title = "title",
+ content = "content",
+ categoryStars = emptyList(),
+ ),
+ )
+ fakeRepository.result = Result.success(expected)
+
+ // when
+ val result = sut(purpose, listOf(today))
+
+ // then
+ assertEquals(expected, result.getOrNull())
+ assertEquals(purpose, fakeRepository.lastPurpose)
+ assertEquals(listOf(today), fakeRepository.lastTargetDates)
+ }
+
+ @Test
+ fun `Repository가_실패하면_실패가_그대로_전파된다`() =
+ runTest {
+ // given
+ val today = LocalDate.now()
+ val expected = IllegalStateException("서버 오류")
+ fakeRepository.result = Result.failure(expected)
+
+ // when
+ val result = sut(purpose, listOf(today))
+
+ // then
+ assertTrue(result.isFailure)
+ assertEquals(expected, result.exceptionOrNull())
+ }
+}
diff --git a/core/domain/src/test/java/com/kikidan/domain/usecase/GetDayFortuneUseCaseTest.kt b/core/domain/src/test/java/com/kikidan/domain/usecase/GetDayFortuneUseCaseTest.kt
new file mode 100644
index 00000000..a43bab5b
--- /dev/null
+++ b/core/domain/src/test/java/com/kikidan/domain/usecase/GetDayFortuneUseCaseTest.kt
@@ -0,0 +1,59 @@
+package com.kikidan.domain.usecase
+
+import com.kikidan.domain.fake.FakeDayFortuneRepository
+import com.kikidan.domain.model.dayfortune.DayFortune
+import com.kikidan.domain.model.dayfortune.DayFortunePurpose
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import java.time.LocalDate
+
+class GetDayFortuneUseCaseTest {
+ private lateinit var fakeRepository: FakeDayFortuneRepository
+ private lateinit var sut: GetDayFortuneUseCase
+
+ @Before
+ fun setUp() {
+ fakeRepository = FakeDayFortuneRepository()
+ sut = GetDayFortuneUseCase(fakeRepository)
+ }
+
+ @Test
+ fun `id를_그대로_Repository에_전달하고_결과를_그대로_반환한다`() =
+ runTest {
+ // given
+ val expected =
+ DayFortune(
+ id = "id-1",
+ purpose = DayFortunePurpose.TRAVEL,
+ targetDate = LocalDate.of(2026, 8, 10),
+ score = 85,
+ title = "title",
+ content = "content",
+ categoryStars = emptyList(),
+ )
+ fakeRepository.getResult = Result.success(expected)
+
+ // when
+ val result = sut("id-1")
+
+ // then
+ assertEquals(expected, result.getOrNull())
+ assertEquals("id-1", fakeRepository.lastGetId)
+ }
+
+ @Test
+ fun `Repository가_실패하면_실패가_그대로_전파된다`() =
+ runTest {
+ // given
+ val expected = IllegalStateException("서버 오류")
+ fakeRepository.getResult = Result.failure(expected)
+
+ // when
+ val result = sut("id-1")
+
+ // then
+ assertEquals(expected, result.exceptionOrNull())
+ }
+}
diff --git a/core/domain/src/test/java/com/kikidan/domain/util/YearGanjiFormatterTest.kt b/core/domain/src/test/java/com/kikidan/domain/util/YearGanjiFormatterTest.kt
new file mode 100644
index 00000000..42c744b1
--- /dev/null
+++ b/core/domain/src/test/java/com/kikidan/domain/util/YearGanjiFormatterTest.kt
@@ -0,0 +1,21 @@
+package com.kikidan.domain.util
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class YearGanjiFormatterTest {
+ @Test
+ fun `1984년은_갑자년이다`() {
+ assertEquals("갑자년(甲子年)", yearToGanji(1984))
+ }
+
+ @Test
+ fun `2024년은_갑진년이다`() {
+ assertEquals("갑진년(甲辰年)", yearToGanji(2024))
+ }
+
+ @Test
+ fun `2026년은_병오년이다`() {
+ assertEquals("병오년(丙午年)", yearToGanji(2026))
+ }
+}
diff --git a/designs/issue-107-date-fortune.md b/designs/issue-107-date-fortune.md
new file mode 100644
index 00000000..1ed5d4fc
--- /dev/null
+++ b/designs/issue-107-date-fortune.md
@@ -0,0 +1,488 @@
+# 설계 문서 — #107 [Feat] 택일 운세 페이지 구현
+
+> 이 문서는 **설계(Design) 단계 산출물**이며 구현 단계가 이 문서를 단일 소스로 삼아 진행한다.
+
+- **이슈**: [#107](https://github.com/YAPP-Github/28th-App-Team-2-Android/issues/107)
+- **작성**: 설계 에이전트 (Opus) + 코디네이터 정리 / 2026-08-10
+- **상태**: `승인됨(구현 가능) — Part A, Part B1/B2(블로커 B-2 제외 전부 확정)`
+
+---
+
+## 1. 범위
+
+### 문제 정의 / 목표
+
+사용자가 **목적**(계약·이사 / 개업 / 여행 / 고백·소개팅 / 시험·면접)과 **후보 날짜(최대 5개)** 를 고르면, 날짜별 택일 운세(점수 · 제목 · 요약 분석 · 카테고리별 별점)를 받아 보여준다.
+
+서버가 사주를 JWT로 식별하므로 앱에서 생년월일 등 사주 입력을 다시 받지 않는다(2절 참고 스펙 근거). 단, **성별 선택 UI는 예외** — 사주 재입력이 아니라 이 화면 전용 신규 입력값으로 확인됐다(4절 블로커 B-2).
+
+완료 기준(Acceptance Criteria):
+
+**Part A — 데이터/도메인**
+- [ ] `POST /api/v1/day-fortunes` 호출로 `List` 도메인 모델을 얻는 경로가 완성된다.
+- [ ] 성공/실패가 `Result`로 표현되고 예외가 UI까지 raw로 새지 않는다.
+- [ ] 목적 5종 · 카테고리 5종이 도메인 enum이며, 서버가 모르는 값을 보내도 앱이 죽지 않는다.
+- [ ] `./gradlew :core:domain:test :core:data:test :core:data-remote:test` 통과.
+
+**Part B1 — 입력 화면 + 캘린더 바텀시트**
+- [ ] 목적 칩 5개 중 1개 선택 → 선택 상태가 시각적으로 구분된다.
+- [ ] 성별(남성/여성) 토글이 동작하고 상태에 반영된다. ※ 서버 전송은 B-2 해소 후 (전송 안 해도 완료로 본다)
+- [ ] `SelectField` 탭 → 캘린더 바텀시트가 열린다.
+- [ ] 캘린더에서 과거 날짜는 선택 불가, 오늘은 선택 가능, **5개** 초과 시 토스트.
+- [ ] 선택 날짜가 칩으로 표시되고 개별 삭제·전체 초기화가 동작한다.
+- [ ] 목적 미선택 또는 날짜 0개면 CTA 비활성.
+- [ ] 제출 중 CTA 비활성 + 인디케이터, 실패 시 스낵바.
+
+**Part B2 — 결과 화면**
+- [ ] 날짜 탭 전환 시 해당 날짜의 점수·제목·본문·별점이 바뀐다.
+- [ ] 응답의 `fortuneCategories` 개수만큼 카테고리 칸이 그려진다 (1~5개 모두 깨지지 않음).
+- [ ] 별은 `star` 값만큼 채워 그린다 (상한 상수 없음).
+- [ ] 다크 배경 + 카드 레이아웃이 Figma와 일치한다.
+- [ ] 로딩/에러 상태가 화면에 표현된다.
+
+**공통**
+- [ ] 색·타이포·간격이 `core:designsystem` 토큰만 사용 (하드코딩 0건).
+- [ ] `./gradlew ktlintCheck` 통과.
+
+### 포함
+- `core:domain` / `core:data` / `core:data-remote` — 택일 운세 API 연동 (Part A)
+- `feature:saju-contents` — 입력 화면 + 캘린더 바텀시트 + 결과 화면 (Part B1/B2)
+- `core:designsystem` — 캘린더 컴포넌트 신규, `TodakunChip` 버그 수정, 별점/공유 아이콘 추가
+
+### 제외 (Non-goals)
+- **앱 전역 Navigation 3 호스트 구축.** `core:navigation`에 `src/main` Kotlin 소스가 한 줄도 없고 `MainActivity`는 아직 `Greeting("Android")` 스텁이다. → **화면 컴포저블만 만들고 `MainActivity`에서 임시 직배선한다.** 정식 nav3 인프라 구축은 별도 이슈로 분리 (사람 확정).
+- **"토닥이에게 더 물어보기" 채팅 연결.** 채팅 화면 자체가 미구현. 버튼은 배치하되 콜백만 노출, 배선은 후속 이슈.
+- **결과 공유 / 캘린더 내보내기 실제 동작.** Swagger에 대응 엔드포인트가 없다. → 아이콘만 배치, 콜백은 no-op (사람 확정).
+- **유료/프리미엄 게이팅.** CTA "무료로 택일 보기"는 마케팅 문구로 취급한다. Swagger에 무료/유료 구분 필드가 전혀 없다. → **게이팅 없음, 전체 무료로 구현** (사람 확정).
+- **성별 값의 서버 전송.** UI는 만들되 전송은 백엔드 대응 후 (블로커 **B-2**, 4절).
+- 로컬 캐싱. 서버가 멱등하다 ("이미 생성된 (목적, 날짜) 조합이 있다면 기존 결과를 반환한다").
+
+---
+
+## 2. 설계 및 실행 계획
+
+### 2-1. 참고 스펙
+
+#### Swagger — 확정 ✅
+
+**`POST /api/v1/day-fortunes`** — "택일 운세 생성"
+> 회원이 선택한 목적과 후보 날짜(최대 5개)에 대한 택일 운세를 생성한다. 이미 생성된 (목적, 날짜) 조합이 있다면 기존 결과를 반환한다(멱등).
+
+Request — `CreateDaySelectionFortuneRequest`
+
+| 필드 | 타입 | 제약 |
+|------|------|------|
+| `purpose` | `string` | 필수 |
+| `targetDates` | `string[]` (`format: date`) | 필수. **minItems 1 / maxItems 5**, 과거 날짜 불가 |
+
+Response — `CommonResponse>`
+
+| 필드 | 타입 | 비고 |
+|------|------|------|
+| `id` | `string` (uuid) | 필수 |
+| `purpose` | enum | `CONTRACT_MOVING`, `BUSINESS_OPENING`, `TRAVEL`, `CONFESSION_DATING`, `EXAM_INTERVIEW` |
+| `targetDate` | `string` (date) | 필수 |
+| `score` | `int32` | 필수. Figma 예시 85 |
+| `title` | `string` | 필수. Figma 예시 "이 날짜엔 새로운 시작이 아주 잘 맞아요." |
+| `content` | `string` | 필수. "요약 분석" 본문 |
+| `fortuneCategories` | `[{ fortuneCategory, star }]` | enum: `RELATIONSHIP`, `LOVE`, `ACHIEVEMENT`, `MONEY`, `HEALTH` / `star`: int32 (상한 미문서화) |
+
+확정되는 사실: 목적은 정확히 5종 · **후보 날짜 상한 5개 확정**(`maxItems=5` + Figma 유효성검사 토스트 등 6개 노드 근거) · 사주 입력 UI 불필요(`GET /api/v1/saju/me`로 서버가 이미 조회) · 1회 호출로 최대 5일치 동시 수신 · 인증은 `NetworkModule`의 `installBearerAuth`로 이미 해결됨.
+
+#### Figma — 8개 노드 전수 조사 결과 ✅ (TalkToFigma 소켓, 채널 `qbnf3csu`)
+
+파일 `bLZr7Nh53PmRHuEjX7gNco`. 모든 프레임 393x852 기준(iPhone).
+
+| # | node-id | 프레임 이름 | 역할 |
+|---|---------|-------------|------|
+| 1 | `1086:24067` | 택일 운세 default | 입력 화면 — 초기(빈) 상태 |
+| 2 | `1100:7253` | 택일 운세 filled | 입력 화면 — 목적·날짜 채워진 상태 (성별 칩 포함) |
+| 3 | `1099:6279` | 후보 날짜 선택_default | 캘린더 바텀시트 — 선택 0개 |
+| 4 | `1099:6584` | 후보 날짜 선택_선택중 | 캘린더 바텀시트 — 2개 선택, 칩 행 없음(갱신 누락) |
+| 5 | `1099:6998` | 후보 날짜 선택_선택중 | 캘린더 바텀시트 — 2개 선택 + 칩 행 (최신) |
+| 6 | `1284:7825` | 후보 날짜 선택_유효성검사 | 캘린더 바텀시트 — 상한 초과 토스트 ("5개까지만") |
+| 7 | `2926:29057` | 택일 운세 - 상세 | 결과 화면 (height 1175, 스크롤 전체) |
+| 8 | `2926:29649` | 택일 운세 - 상세 | 7번과 동일 + 딤 오버레이 (중복) |
+
+**(A) 입력 화면 — `1086:24067` / `1100:7253`**
+```
+Status Bar 52 / Progress bar 393x48 (chevron 20 + track 316x6, 진행률 210/316≈66% + "택일 운세")
+"무슨 날을 정하시나요?" Noto Sans KR Bold 24 / #171717
+[성별 선택] 168.5x48 두 칸(남성/여성), r8, 그라데이션 배경 ← 실제 기능 확정 (블로커 B-2)
+목적 칩 353x108 (2줄, gap 12): 계약·이사(선택 fill #9c8af6) / 개업 / 여행 / 고백·소개팅 / 시험·면접
+ 비선택: fill #f5f5f5, text #505866
+"후보 날짜 (5개까지 가능)" Noto Sans KR Bold 24 + Pretendard Regular 14 / #737373
+SelectField 353x48 r12 fill #fafafa — default: placeholder / filled: 선택 날짜 나열 + 개별삭제 + chevron
+CTA 353x52 r12 fill #7f73ea "무료로 택일 보기"
+```
+
+**(B) 캘린더 바텀시트 — `1099:6279`/`1099:6584`/`1099:6998`/`1284:7825`**
+```
+Bottom Sheet 393x716, Handle 32x4
+"후보 날짜 선택" + "5개까지 날짜 선택 가능해요"
+월 리스트(세로 스크롤) — 요일행 + 날짜셀 44~48x44~48 (선택 상태 그린 노드 기준 48로 채택, 터치타겟 권장치와도 부합)
+ 기본 #171717 / 오늘 #7f73ea+"오늘"라벨 / 선택 fill #a99af6 원형
+선택 칩 행(≥1일 때만) — 날짜 1개당 칩 1개(범위표기 노드는 더미로 판단), r99 stroke #c1b4f6, 개별삭제
+버튼 행: [초기화] 88x52 / [선택 완료] 257x52 (비활성 #f1f1f1 / 활성 #7f73ea)
+Toast: "후보 날짜는 5개까지만 선택 가능합니다." (#000000, 236x36 r8)
+```
+
+**(C) 결과 화면 — `2926:29057`**
+```
+배경 #00010b + 반투명 이미지(다크 전용, 신규 토큰 필요)
+Header: 뒤로가기 + 진행바(210/316, 동일 스펙) + "택일 운세 결과" + [공유/캘린더내보내기] 아이콘(no-op)
+chip2 "계약 ∙ 이사" (fill #5757d7)
+"선택하신 후보 일자 중 최고의 기운을 찾아봤어요"
+날짜 탭 353x54 — SelectBox N개(gap14, results.size만큼 동적): 선택 fill #7f73ea / 비선택 white@5%
+점수 카드 353x385 r16 fill white@10%
+ 원 180x181 fill #a99af6 → "85" Bold40 + "운세 점수" white@60%
+ 제목 Noto Sans KR Bold 22
+ 카테고리 별점 — Figma 시안은 3칸(재물운/화합운/사고예방)이지만 **실제 구현은 API 응답 개수(최대 5개)를 그대로 렌더링** (2-2절 D9)
+요약 분석 카드 353x286 r16 — "요약 분석" + 본문
+CTA "토닥이에게 더 물어보기"
+```
+
+Figma에 없는 것: 로딩 상태, 에러 상태, 빈 결과 상태, 결과 화면 진행바의 "완료" 단계 표현(입력·결과 모두 210/316로 동일 — 갱신 누락 추정, 4절 Q10) → **로딩은 CTA 비활성+인디케이터, 에러는 스낵바로 확정** (사람 결정).
+
+#### `core:designsystem` 매핑
+
+색상은 대부분 기존 토큰으로 커버됨: `#9c8af6`→`primary500`, `#7f73ea`→`primary600`, `#5757d7`→`primary700`, `#a99af6`→`primary400`, `#c1b4f6`→`primary300`, `#f5f3fe`→`primary50`, `#a8bbff`→`sky400`, `#171717`→`gray975`, `#303030`→`gray925`, `#737373`→`gray700`, `#8a8a8a`→`gray600`, `#b8b8b8`→`gray400`, `#f1f1f1`→`gray100`, `#f5f5f5`→`gray50`, `#fafafa`→`gray25`, `#e8e8e8`→`gray200`, `#505866`→`coolGray700`. 신규 필요: `#00010b`(결과 배경 → `TodakunColor.night`), `#e3e3e4`(핸들 → `gray200`로 근사), `#f7f7f8`(토스트 텍스트 → `white`/`gray25`로 근사).
+
+컴포넌트 재사용 판정:
+
+| Figma | 기존 컴포넌트 | 판정 |
+|-------|---------------|------|
+| SelectField | `TodakunSelectField` | ✅ 그대로 사용 — gray25/r12/h48/placeholder gray600/value gray975/`ic_circle_x_fill`/chevron 전부 일치 |
+| 결과 chip2 | `TodakunChip2` | ✅ `primary700` + `body3Medium` + r100 — 일치 |
+| 목적 칩 | `TodakunChip` | ⚠️ **버그 3건 발견, 수정 필요** (아래) |
+| 성별 토글 | 없음 | ❌ **`GenderSelector` 신규.** Figma는 168.5x48 r8 + 그라데이션 2종(`#f3f6ff→#f5f5f5`, `#f5f5f5→#f5f3fe`), 텍스트 `#5c5c5c`(`gray800`) Medium 18 — 기존 컴포넌트와 형태가 다름 |
+| 진행바 | `TodakunProgressBar` | ⚠️ 뒤로가기 아이콘이 `ic_arrow_back` 8x16/gray400인데 Figma는 chevron 20x20 — 파라미터화 필요 |
+| 토스트 | `TodakunSnackbar` | 🔍 색/치수 대조 필요 (Figma: 236x36 r8 #000000) |
+| 툴팁 | `TodakunTooltip`/`TodakunPopover` | 🔍 꼬리 달린 gray925 r99 형태와 대조 필요 |
+| 바텀시트 | `WheelPickerBottomSheet` | ⚠️ `internal` + 휠 전용 — 캘린더용은 분리 필요 |
+| **캘린더** | 없음 | ❌ **신규 개발** — 이 이슈 최대 공수. `feature:saju-contents`에 먼저 만들고(사용처 1개), 두 번째 사용처가 생기면 designsystem으로 승격 (사람 확정) |
+| **별점(STAR)** | 없음 | ❌ 아이콘 에셋 + 소형 컴포저블 신규 |
+
+**`TodakunChip` 버그** (`core/designsystem/.../component/TodakunChip.kt`) — 현재 사용처가 0곳이라 **이 이슈에서 바로 고친다** (사람 확정):
+```kotlin
+// 현재 → 수정
+style = if (selected) typography.body2Medium else typography.body2SemiBold // 굵기가 뒤바뀜 → 선택=SemiBold, 비선택=Medium
+color = if (selected) ... else colors.gray25 // → gray50 (#f5f5f5)
+color = if (selected) ... else colors.gray700 // → coolGray700 (#505866)
+```
+
+필요한 신규 drawable: `ic_star_fill.xml`(규칙 40 `ic_` 접두사), `ic_share.xml`/`ic_event_export.xml`(아이콘 배치 확정에 따라 필요), 결과 화면 배경 `img_date_fortune_bg`(`img_` 접두사) — **아이콘·이미지 전부 사람이 Figma에서 export해 전달해야 한다** (에이전트가 노드 JSON만으로는 이미지 에셋을 추출할 수 없음, 4절 N5).
+
+#### 운세 카테고리 한국어 라벨 (⚠️ 임시값 — 기획 확정 전)
+
+| enum | 라벨 | 근거 |
+|------|------|------|
+| `MONEY` | 재물운 | Figma `2926:29057`에 실재 |
+| `RELATIONSHIP` | 관계운 | Figma는 "화합운" — 임시 |
+| `HEALTH` | 건강운 | Figma는 "사고 예방" — 임시 |
+| `LOVE` | 애정운 | Figma에 없음 — 임시 |
+| `ACHIEVEMENT` | 성취운 | Figma에 없음 — 임시 |
+
+전부 `strings.xml` 리소스로 뺀다. 기획이 확정되면 문자열만 교체하면 되고 코드는 손대지 않는다.
+
+### 2-2. 핵심 설계 결정
+
+**D1. Repository 1 · UseCase 1 · ViewModel 1.** 엔드포인트가 하나뿐이고 조합할 다른 관심사가 없다. nav3 그래프가 없어 화면 간 배관 비용이 크므로 `step`을 상태로 든 단일 위저드 ViewModel이 가장 적은 코드로 동작한다. 결과 화면까지 같은 ViewModel이 담당한다.
+
+**D2. `FortuneCategory`는 처음부터 공용 위치에.** `RELATIONSHIP/LOVE/ACHIEVEMENT/MONEY/HEALTH`는 Swagger에서 `DayFortune`·`LuckAction`·`DailyFortune` 3곳이 동일하게 쓴다 → `core/domain/model/fortune/FortuneCategory.kt` 단독 배치 (규칙 00 "공유 필요 시 domain으로 승격").
+
+**D3. DTO는 날짜·enum을 `String` raw로, 도메인은 `LocalDate`·enum.** 규칙 20 "DTO는 Raw 유지". kotlinx-serialization이 `java.time.LocalDate`를 기본 지원하지 않아 커스텀 serializer가 필요한데 Raw 원칙에 어긋난다. Mapper에서 `LocalDate.parse()`. `core:domain`은 순수 `java-library`(JVM) + minSdk 26이라 desugaring 없이 `java.time` 사용 가능.
+
+**D4. Mapper는 Response 파일 안 확장 함수.** `RemoteAuthDataSourceImpl`이 `dto.auth.toDomain`을 import하는 기존 선례와 동일. 파일 4개를 아낀다.
+
+**D5. 서버가 모르는 enum 값에 죽지 않기.** DTO는 `String`으로 받고 Mapper에서 이름 매칭. `purpose` 미매칭은 예외(→`Result.failure`), `fortuneCategories`의 미지 항목은 필터링해서 버린다.
+
+**D6. 캘린더는 `core:designsystem`이 아니라 `feature:saju-contents`에 먼저 만든다.** 사용처가 하나뿐인 지금 designsystem으로 올리면 "쓰는 곳 하나짜리 공용 컴포넌트"가 된다. 다중 선택·과거 비활성·오늘 표시는 처음부터 파라미터로 열어 둬 승격 비용을 낮춘다.
+
+**D7. 후보 날짜 상한은 상수 1곳(`DateFortuneDefaults.MAX_TARGET_DATES = 5`)에서만 정의한다.** UseCase 검증·캘린더 시트·안내 문구·토스트 문구가 전부 이 상수를 참조한다.
+
+**D8. 결과 화면은 다크 테마가 아니라 "어두운 배경 위의 화면"으로 취급한다.** `TodakunTheme`이 `content()` 통과 함수라 다크/라이트 전환 개념이 없다. `#ffffff@10%` 같은 값은 `TodakunColor.white.copy(alpha = 0.1f)`로 표현하고, `#00010b`만 신규 토큰(`TodakunColor.night`)으로 추가한다.
+
+**D9. 카테고리 별점은 개수를 고정하지 않는다.** Figma는 3칸×별3개 고정 레이아웃이지만, 그건 목업 데이터의 모양이지 계약이 아니다. 계약은 Swagger이고 카테고리 5종에 `star: int32`(상한 미문서화)다. → **칸 수 = `categoryStars.size`, 별 개수 = `star` 값. 상한 상수를 두지 않는다.** 상한을 3으로 박으면 서버가 4를 보내는 순간 조용히 잘리고, 5로 박으면 3을 보낼 때 빈 별이 의미 없이 남는다. "빈 별"은 그리지 않는다 — 총점 개념이 없어 분모가 없다. 레이아웃은 Figma의 3칸/gap28을 5칸에 그대로 쓰면 라벨("사고 예방" 5자)이 깨지므로 **`FlowRow`로 감싸 자동 줄바꿈**(칸 최소폭 80dp, gap 12dp) — 3개면 한 줄, 5개면 자연스럽게 3+2 두 줄이 되어 별도 분기가 필요 없다.
+
+**D10. 성별 UI와 성별 전송을 분리한다.** 성별은 실제 기능으로 확정됐지만(Figma 2개 노드에 실재) 서버 Request에 필드가 없다. 둘을 한 커밋에 묶으면 백엔드 대기 때문에 UI까지 통째로 멈춘다. → **State·UI는 지금 만들고, UseCase 시그니처는 건드리지 않는다.** `selectedGender`는 ViewModel 상태에만 존재하고 `CreateDayFortunesUseCase(purpose, targetDates)` 호출에는 들어가지 않는다. 백엔드 대응 후 변경점은 정확히 3줄: `CreateDayFortuneRequest`에 `gender` 추가 / DataSource 파라미터 추가 / UseCase 파라미터 추가. `Gender` enum은 지금 `core:domain`에 만든다(`model/user/Gender.kt`) — UI가 `String`을 들고 있다가 나중에 enum으로 바꾸는 것보다 처음부터 타입을 잡는 편이 나중 diff를 줄인다.
+
+### 2-3. 기존 코드베이스 실사
+
+| 항목 | 실제 상태 | 영향 |
+|------|-----------|------|
+| `feature/saju-contents` | `build.gradle.kts` + 빈 매니페스트만. 소스 0개 | 패키지 구조를 이번에 정한다 |
+| `todakun.feature` 컨벤션 플러그인 | Compose·Hilt·KSP·Orbit(core/viewmodel/compose)·`orbit-test`·`hilt-navigation-compose`·`core:domain`/`navigation`/`designsystem`·`kotlinx-collections-immutable` 전부 포함 | `build.gradle.kts` 수정 불필요 |
+| `feature/auth` | presentation 없음. OAuth 로직만 | Compose+Orbit 선례가 없다 — 이번이 최초 |
+| `core:navigation` | `src/main` Kotlin 소스 0개, nav3 의존성도 없음 | 라우트 인프라는 별도 이슈 |
+| `MainActivity` | `Greeting("Android")` 스텁 | 이번 이슈에서 임시 직배선 |
+| `core:designsystem` | 토큰 완비. 컴포넌트 다수 존재하나 캘린더 없음 | 2-1절 |
+| `TodakunTheme` | `content()` 통과 함수 | `MaterialTheme.*` 쓰지 말 것 |
+| Ktor Bearer | `installBearerAuth` 전역 적용 | 인증 작업 0 |
+| ktlint | 루트에서 전 서브프로젝트 + `nlopez-compose-rules` 적용 중 | CLAUDE.md 6절 "미설정 TODO"는 이미 해소됨 |
+
+### 2-4. 실행 순서와 의존성
+
+```
+Part A (domain+data) ─────┐ gender 없이 진행 (B-2)
+ ↑ 즉시 착수 가능 │
+ ├──▶ Part B1 (입력 + 캘린더 시트 + GenderSelector)
+ │ · MAX_TARGET_DATES = 5 (확정)
+ │ · selectedGender 는 State 까지만, UseCase 로 전달 X
+ │
+ └──▶ Part B2 (결과 화면)
+ · 카테고리 N칸 동적 (확정)
+ · ⛔ 배경 이미지 · 별 아이콘 export 필요 (4절 N5)
+ │
+ ┌───────────────┴───────────────┐
+ ▼ ▼
+ Part C (MainActivity 임시 배선) Part D ⛔ gender 전송
+ ↑ 블로커 B-2 해소 후
+ ↑ DTO/DataSource/UseCase 3줄
+```
+Part A는 지금 착수 가능, B1/B2와 병렬. B1과 B2는 같은 ViewModel을 쓰지만 화면 파일이 분리돼 병렬 가능. Part D(성별 전송)는 이 이슈에서 하지 않는다 — 백엔드가 필드를 추가한 뒤 후속 커밋/이슈로 처리한다.
+
+### 2-5. 트레이드오프
+
+| 고민 | 선택 | 버린 것 |
+|------|------|---------|
+| 날짜 상한 3 vs 5 | **5 확정** (+ 상수 1곳) | Figma 내부 불일치가 있었으나 Swagger+다수 노드 근거로 5 채택. 3이 맞다면 상수 1줄 수정으로 끝남 |
+| 카테고리 칸 수 고정 vs 동적 | **동적**(`categoryStars.size`) | Figma 픽셀 재현. 3칸 고정은 서버가 4~5개 보내면 조용히 잘린다 |
+| 별 최대 개수 상수 vs 응답 그대로 | **응답 그대로** | "★★★☆☆" 식 총점 표현. Swagger에 상한이 없어 분모를 만들 근거가 없다 |
+| 성별: UI+전송 한 번에 vs UI만 먼저 | **UI만 먼저** | 기능 완결성. 묶으면 백엔드 대기로 UI까지 멈춘다. 나중 diff는 3줄 |
+| `Gender` enum 위치: feature vs domain | **domain** | 곧 UseCase 파라미터가 되는 게 확정이라 feature→domain 이동 비용이 지금 작성 비용보다 크다 |
+| 화면별 ViewModel 3개 vs 1개 | **1개** | 단계별 격리. nav3 배관이 없는 지금은 비용만 크다 |
+| 캘린더 위치: designsystem vs feature | **feature 먼저** | 선제적 공용화. 사용처 1개짜리 공용 컴포넌트를 피했다 |
+| `TodakunChip` 수정 지금 vs 나중 | **지금** | 범위 최소화. 현재 사용처가 0이라 지금이 가장 싸다 |
+| 결과 화면 다크: 테마 확장 vs 로컬 처리 | **로컬 처리** | `TodakunTheme`이 빈 껍데기라 테마 확장은 이 이슈 범위를 넘는다 |
+| 모듈: saju-contents vs 신규 feature | **saju-contents** | #105가 이미 "사주 콘텐츠 묶음"으로 만들어 뒀다 |
+
+### 2-6. 데이터 흐름
+
+```
+[Compose] DateFortuneInputScreen
+ │ onPurposeSelect / onGenderSelect / onOpenDateSheet / onDateToggle / onDateRemove / onReset / onSubmit
+ ▼
+[Orbit] DateFortuneViewModel : ContainerHost by container(...)
+ ▼
+[UseCase] CreateDayFortunesUseCase(purpose, targetDates) : Result>
+ │ └ 1..MAX_TARGET_DATES 개수 / 과거 날짜 사전 검증 (서버 왕복 전)
+ ▼
+[Repository] DayFortuneRepository.createDayFortunes(...) : Result>
+ │ impl: runCatchingCancellable { ... } ← 규칙 20
+ ▼
+[DataSource] RemoteDayFortuneDataSource.postDayFortunes(...) : List
+ │ impl: client.post("api/v1/day-fortunes") { setBody(CreateDayFortuneRequest(...)) }
+ │ .bodyNotNull>().map { it.toDomain() }
+ │ ※ try-catch 하지 않고 그대로 throw (규칙 20)
+ ▼
+[Ktor] POST /api/v1/day-fortunes (Bearer 자동 첨부 + 401 자동 refresh)
+```
+반환 경로는 역순. 예외는 `DayFortuneRepositoryImpl`의 `runCatchingCancellable`에서 한 번만 `Result.failure`로 봉인되고 `CancellationException`은 통과한다. 성공 시 ViewModel이 `results: ImmutableList` + `selectedResultIndex = 0`으로 reduce하고 `SideEffect.NavigateToResult`를 낸다. `selectedGender`는 State에만 존재하고 UseCase 호출 인자에 들어가지 않는다(D10).
+
+---
+
+## 3. 파일 변경 계획 (구현 체크리스트)
+
+### 3-1. Part A — domain + data (신규 10 / 수정 3 = 13)
+
+> **gender 필드는 넣지 않는다** (블로커 B-2). `Gender` enum만 미리 만들어 둔다(D10).
+
+```
+core/
+├─ domain/
+│ ├─ build.gradle.kts [수정] testImplementation junit + coroutines-test
+│ └─ src/main/java/com/kikidan/domain/
+│ ├─ model/user/Gender.kt [신규] enum { MALE, FEMALE } ※B1 UI 전용, 아직 전송 X
+│ ├─ model/fortune/FortuneCategory.kt [신규] enum 5종 (공용)
+│ ├─ model/dayfortune/DayFortune.kt [신규] DayFortune + DayFortunePurpose + FortuneCategoryStar
+│ ├─ repository/DayFortuneRepository.kt [신규] interface
+│ └─ usecase/CreateDayFortunesUseCase.kt [신규] + DateFortuneDefaults.MAX_TARGET_DATES = 5
+├─ data/src/main/java/com/kikidan/data/
+│ ├─ datasource/RemoteDayFortuneDataSource.kt [신규] interface
+│ ├─ repository/DayFortuneRepositoryImpl.kt [신규]
+│ └─ di/RepositoryModule.kt [수정] @Binds 1줄
+└─ data-remote/src/main/java/com/kikidan/data_remote/
+ ├─ dto/dayfortune/CreateDayFortuneRequest.kt [신규]
+ ├─ dto/dayfortune/DayFortuneResponse.kt [신규] Response + toDomain()
+ ├─ datasource/RemoteDayFortuneDataSourceImpl.kt [신규]
+ └─ di/RemoteDataSourceModule.kt [수정] @Binds 1줄
+```
+
+- [ ] `Gender.kt` — `enum class Gender { MALE, FEMALE }`. UseCase/Repository/DTO 어디에도 아직 넣지 않는다 (B-2)
+- [ ] `FortuneCategory.kt` — `enum class FortuneCategory { RELATIONSHIP, LOVE, ACHIEVEMENT, MONEY, HEALTH }`
+- [ ] `DayFortune.kt` — `data class DayFortune(val id: String, val purpose: DayFortunePurpose, val targetDate: LocalDate, val score: Int, val title: String, val content: String, val categoryStars: List)`, `enum class DayFortunePurpose { CONTRACT_MOVING, BUSINESS_OPENING, TRAVEL, CONFESSION_DATING, EXAM_INTERVIEW }`, `data class FortuneCategoryStar(val category: FortuneCategory, val star: Int)` — 전 필드 `val` (규칙 10, `var`는 P1)
+- [ ] `DayFortuneRepository.kt` — `suspend fun createDayFortunes(purpose: DayFortunePurpose, targetDates: List): Result>` (규칙 10: 반환 `Result`, 위반 P1)
+- [ ] `CreateDayFortunesUseCase.kt` — 동사 시작(규칙 10). `operator fun invoke(purpose, targetDates)`. 개수 1..MAX / 과거 날짜 검증을 여기서. `object DateFortuneDefaults { const val MAX_TARGET_DATES = 5 }` 동거(D7). **gender 파라미터 없음**(B-2)
+- [ ] `RemoteDayFortuneDataSource.kt` — `suspend fun postDayFortunes(...): List` (Domain Model 반환 — 규칙 20)
+- [ ] `DayFortuneRepositoryImpl.kt` — `runCatchingCancellable { ... }` (규칙 20, try-catch 누락 P1)
+- [ ] `RepositoryModule.kt` **[수정]** — `@Binds @Singleton bindDayFortuneRepository`
+- [ ] `CreateDayFortuneRequest.kt` — `@Serializable data class CreateDayFortuneRequest(val purpose: String, val targetDates: List)`
+- [ ] `DayFortuneResponse.kt` — 전 필드 raw `String`/`Int` + `FortuneCategoryStarResponse` + `toDomain()` (LocalDate.parse, enum 이름 매칭, D5)
+- [ ] `RemoteDayFortuneDataSourceImpl.kt` — `companion object { private const val DAY_FORTUNES_URL = "api/v1/day-fortunes" }`. try-catch 금지 (규칙 20, P1)
+- [ ] `RemoteDataSourceModule.kt` **[수정]** — `@Binds @Singleton bindDayFortuneRemoteDataSource`
+- [ ] `core/domain/build.gradle.kts` **[수정]** — 테스트 의존성 추가
+
+### 3-2. Part B1 — 입력 화면 + 캘린더 바텀시트 (신규 9 / 수정 2 = 11)
+
+```
+core/designsystem/src/main/
+├─ java/com/kikidan/designsystem/
+│ ├─ component/TodakunChip.kt [수정] 2-1절 버그 3건
+│ └─ theme/TodakunColor.kt [수정] night = Color(0xFF00010B) ※B2와 공유
+feature/saju-contents/src/main/
+├─ java/com/kikidan/sajucontents/dayfortune/
+│ ├─ DateFortuneContract.kt [신규] State / SideEffect
+│ ├─ DateFortuneViewModel.kt [신규] ContainerHost by container(...)
+│ ├─ DateFortuneInputScreen.kt [신규] Route + stateless Screen + @Preview
+│ └─ component/
+│ ├─ PurposeChipGroup.kt [신규] FlowRow + TodakunChip
+│ ├─ GenderSelector.kt [신규] 남성/여성 토글 (D10 / B-2)
+│ ├─ DateSelectBottomSheet.kt [신규] ModalBottomSheet + 헤더/버튼/칩행
+│ ├─ MultiSelectCalendar.kt [신규] 월 리스트 + 요일행 + 날짜셀
+│ └─ SelectedDateChipRow.kt [신규] r99 stroke 칩 + 삭제
+└─ res/values/strings.xml [신규] 화면 문구
+```
+
+- [ ] `DateFortuneContract.kt` — `data class DateFortuneState(val selectedPurpose: DayFortunePurpose?, val selectedGender: Gender?, val selectedDates: ImmutableList, val isSheetVisible: Boolean, val isLoading: Boolean, val results: ImmutableList, val selectedResultIndex: Int)` — 컬렉션은 `ImmutableList`(규칙 40, P2). `sealed interface DateFortuneSideEffect { data class ShowToast(val messageRes: Int); data class ShowError(val messageRes: Int); data object NavigateToResult; data object NavigateBack }`. 파생값 `val canSubmit get() = selectedPurpose != null && selectedDates.isNotEmpty() && !isLoading`. ⚠️ `selectedGender`는 `canSubmit` 조건에 넣지 않는다 — 서버로 안 보내는 값을 필수로 걸면 사용자가 이유 없이 막힌다(B-2)
+- [ ] `DateFortuneViewModel.kt` — `@HiltViewModel class DateFortuneViewModel @Inject constructor(private val createDayFortunes: CreateDayFortunesUseCase) : ViewModel(), ContainerHost<...> by container(...)` — `by container(...)` 형태(규칙 30). 도메인 로직 직접 수행 금지(규칙 30, P2). 날짜 토글 시 5개 초과면 `ShowToast`(상태 변경 없음). 제출 시 `createDayFortunes(purpose, dates)` — **`selectedGender`를 넘기지 않는다**(B-2), `// ponytail: gender는 서버 Request 필드 추가 후 전달 (블로커 B-2)` 주석 1줄. 실패 시 `isLoading=false` + `ShowError`
+- [ ] `DateFortuneInputScreen.kt` — Route + stateless Screen + `@Preview`. 제출 중 CTA 비활성 + 인디케이터
+- [ ] `PurposeChipGroup.kt` — `FlowRow` + `TodakunChip`, 2줄 배치
+- [ ] `GenderSelector.kt` — `Row` 2등분, 각 168.5x48 `r8`. 배경 그라데이션 2종(`Brush.horizontalGradient`): 좌 `sky50→gray50`, 우 `gray50→primary50`. 텍스트 `gray800` Medium 18. ⚠️ Figma에 "선택됨" 상태 시안이 없다 — 선택 시 `primary500` 배경 + `white` 텍스트로 목적 칩과 동일 규칙 적용 (4절 Q12)
+- [ ] `DateSelectBottomSheet.kt` — 핸들/타이틀/"5개까지 날짜 선택 가능해요"(상수 주입)/닫기/[초기화][선택완료]
+- [ ] `MultiSelectCalendar.kt` — `LazyColumn` 월 섹션 반복. 셀 상태 4종(기본/오늘/선택/과거비활성)
+- [ ] `SelectedDateChipRow.kt` — 날짜 1개당 칩 1개(D7). `M.d(E)` 포맷
+- [ ] `strings.xml` — 목적 5종, 성별 2종, 카테고리 5종(2-1절 임시값), 안내·토스트·에러 문구. 개수 문구는 `%d` 플레이스홀더 필수
+- [ ] `TodakunChip.kt` **[수정]**, `TodakunColor.kt` **[수정]**
+
+### 3-3. Part B2 — 결과 화면 (신규 7 / 수정 0 = 7 + 에셋 3)
+
+> 카테고리 칸 수는 `categoryStars.size`, 별 개수는 `star` 값. 고정 상수 없음(D9).
+
+```
+core/designsystem/src/main/res/drawable/
+├─ ic_star_fill.xml [신규·에셋] 벡터 (규칙 40 ic_ 접두사)
+├─ ic_share.xml [신규·에셋] 벡터
+└─ ic_event_export.xml [신규·에셋] 벡터
+feature/saju-contents/src/main/
+├─ java/com/kikidan/sajucontents/dayfortune/
+│ ├─ DateFortuneResultScreen.kt [신규] Route + stateless Screen + @Preview
+│ └─ component/
+│ ├─ FortuneScoreCard.kt [신규] 원형 점수 + 제목 + 카테고리 영역
+│ ├─ FortuneCategoryStars.kt [신규] FlowRow, N칸 동적
+│ ├─ CategoryStarItem.kt [신규] 라벨 + 별 N개 (칸 1개)
+│ └─ ResultDateTabRow.kt [신규] SelectBox N개 상태
+└─ res/drawable/img_date_fortune_bg.webp [신규·에셋] Figma export 필요
+```
+
+- [ ] `DateFortuneResultScreen.kt` — 배경 `TodakunColor.night` + 배경 이미지(`alpha=0.5f`), `verticalScroll`. 헤더 우측 `ic_event_export`+`ic_share`는 `onClick={}` no-op. CTA도 콜백만 노출(채팅 미구현)
+- [ ] `FortuneScoreCard.kt` — 카드 `white.copy(alpha=0.1f)` r16, 원 `primary400` 180dp, 점수 Bold 40 + "운세 점수" `white@60%`
+- [ ] `FortuneCategoryStars.kt` — `FlowRow`(`spacedBy(12.dp)` 가로/세로), 아이템 `widthIn(min=80.dp)`. `categoryStars.forEach { CategoryStarItem(it) }` — 개수 분기 없음. 빈 리스트면 영역 자체를 그리지 않는다
+- [ ] `CategoryStarItem.kt` — 라벨(`stringResource`) `white@60%` Regular 12 + `repeat(star) { Icon(ic_star_fill, tint=primary400) }`. 빈 별(`ic_star_line`)은 만들지 않는다 — 총점 분모가 없다(D9). `star<=0`이면 별 영역 생략
+- [ ] `ResultDateTabRow.kt` — 선택 `primary600`+`primary500` 테두리 / 비선택 `white@5%`+`white@30%`. 탭 개수는 `results.size`에 따라 동적(1~5개)
+- [ ] 배경 이미지·별/공유/내보내기 아이콘 export — **사람 작업 필요** (4절 N5)
+
+### 3-4. Part C — app 배선
+
+- [ ] `app/build.gradle.kts` **[수정]** — `implementation(projects.feature.sajuContents)` (현재 `feature.auth`만)
+- [ ] `MainActivity.kt` **[수정]** — `Greeting("Android")` 대신 `DateFortuneInputRoute` 임시 직배선. 정식 nav3 인프라는 별도 이슈
+
+### 3-5. 테스트 계획
+
+**Part A**
+> ⚠️ `core/domain/build.gradle.kts`에 테스트 의존성이 없다. `testImplementation(libs.junit)` + `libs.kotlinx.coroutines.test` 선행.
+
+`CreateDayFortunesUseCaseTest` (Fake Repository)
+- [ ] 날짜 0개 → `failure`, Repository 미호출
+- [ ] 날짜 `MAX+1`개(6개) → `failure`, Repository 미호출
+- [ ] 과거 날짜 포함 → `failure`, Repository 미호출
+- [ ] 오늘 날짜 → 통과 (경계값: "과거 불가"이지 "미래만"이 아니다)
+- [ ] 날짜 정확히 5개 → 통과 (상한 경계)
+- [ ] 유효 입력 → Repository 반환값 그대로 전달
+- [ ] Repository 실패 → 실패 전파
+
+`DayFortuneRepositoryImplTest` (`FakeRemoteDayFortuneDataSource`, 기존 `core/data/src/test/.../fake/` 패턴)
+- [ ] DataSource 정상 → `success`, 값 동일
+- [ ] DataSource `IOException` throw → `failure`, 예외가 밖으로 새지 않음
+- [ ] DataSource `CancellationException` throw → `Result`로 감싸지 않고 전파
+
+`RemoteDayFortuneDataSourceImplTest` (`ktor-client-mock`, 기존 `RemoteAuthDataSourceImplTest` 패턴)
+- [ ] 경로 `api/v1/day-fortunes`, 메서드 `POST`
+- [ ] body의 `purpose`가 enum name 문자열, `targetDates`가 `yyyy-MM-dd` 배열
+- [ ] 정상 응답 → `targetDate`가 `LocalDate`, `fortuneCategory`가 enum으로 매핑
+- [ ] `data`가 `null` → `bodyNotNull`이 `IllegalArgumentException` throw
+- [ ] 알 수 없는 `fortuneCategory` 포함 → 해당 항목만 제외, 나머지 정상 (D5)
+- [ ] 알 수 없는 `purpose` → 예외 throw (D5)
+- [ ] 5xx → 예외를 삼키지 않고 throw
+
+**Part B** — `DateFortuneViewModelTest` (`orbit-test`)
+- [ ] 목적 선택 → `selectedPurpose` 변경
+- [ ] 날짜 토글(추가/제거) → `selectedDates` 반영
+- [ ] 5개 선택 상태에서 추가 시도 → 상태 불변 + `ShowToast`
+- [ ] 초기화 → `selectedDates` 비고 CTA 비활성
+- [ ] 목적 미선택 또는 날짜 0개 → `canSubmit == false`
+- [ ] 성별 선택 → `selectedGender` 변경
+- [ ] 성별 미선택이어도 `canSubmit == true` (목적·날짜만 충족하면) — B-2 회귀 방지용
+- [ ] 제출 시 UseCase에 전달된 인자가 `(purpose, dates)` 2개뿐 — gender가 새어 들어가지 않음을 고정 (B-2 해소 시 이 테스트를 의도적으로 깨고 갱신)
+- [ ] 제출 → `isLoading=true` → 성공 시 `false` + `results` 반영 + `NavigateToResult`
+- [ ] 제출 중 `canSubmit == false` (중복 제출 방지)
+- [ ] UseCase 실패 → `isLoading=false` + `ShowError`, 크래시 없음
+- [ ] 결과 탭 전환 → `selectedResultIndex` 변경
+
+Compose UI 계측 테스트는 이 이슈에서 필수로 두지 않는다(`@Preview` + 유닛 테스트로 충분). 단 캘린더 셀 상태 판정(과거/오늘/선택)은 순수 함수로 분리해 유닛 테스트를 붙인다.
+- [ ] `CalendarDayState` 판정 순수 함수 테스트: 과거 / 오늘 / 미래·미선택 / 미래·선택
+
+**검증 명령**
+```bash
+./gradlew :core:domain:test :core:data:test :core:data-remote:test # Part A
+./gradlew :feature:saju-contents:test # Part B
+./gradlew ktlintCheck
+```
+
+---
+
+## 4. 리스크 / 미해결 질문 (사람 확인 필요)
+
+### 🔴 남은 블로커
+
+- [ ] **B-2. 성별(gender) 서버 전송.** Figma 2개 노드(`1086:24067`, `1100:7253`)에 성별 선택 UI가 실제로 존재하는 것으로 확정됐으나, 현재 Swagger `CreateDaySelectionFortuneRequest`에는 gender 필드가 없다. **백엔드가 필드를 추가해야 실제 전송이 가능하다.** 이번 이슈에서는 `GenderSelector` UI와 State만 만들고, 값 전송은 백엔드 대응 후 후속 커밋으로 미룬다 (2-2절 D10, 3-1/3-2절 반영 완료).
+
+### ✅ 결정 완료 (사람 확인 완료)
+
+| # | 쟁점 | 결정 |
+|---|------|------|
+| D-1 | 후보 날짜 상한 3 vs 5 | **5.** Swagger `maxItems=5` + Figma 유효성검사 토스트 등 6개 노드 근거 |
+| D-2 | 결과 카테고리 3칸 vs API 5종 | **API 응답 개수(최대 5개) 그대로 렌더링.** 한국어 라벨 5종은 임시값(2-1절), 기획 확정 시 `strings.xml`만 교체 |
+| D-3 | "남성/여성" 칩 | **실제 기능.** 서버 전송은 블로커 B-2 해소 후 |
+| D-4 | 무료/유료 게이팅 | **게이팅 없음, 전체 무료** |
+| D-5 | 로딩/에러 상태 | **CTA 비활성+인디케이터(로딩) / 스낵바(에러)** |
+| Q1 | 앱 네비게이션 범위 | **화면만 구현 + `MainActivity` 임시 직배선.** 정식 nav3 인프라는 별도 이슈 |
+| Q7 | 공유/캘린더 내보내기 아이콘 | **아이콘만 배치, 콜백 no-op** |
+| Q8 | `TodakunChip` 버그 수정 시점 | **이 이슈에서 즉시 수정** |
+| Q9 | 캘린더 위치 | **`feature:saju-contents`에 먼저 구현**, 2번째 사용처 등장 시 designsystem 승격 검토 |
+
+### 🟠 열려 있는 질문 (구현에 큰 영향 없음, 진행하며 확인)
+
+- [ ] **Q10. 진행바 단계 수 불명.** 입력·결과 화면 모두 진행률 210/316(≈66%)로 동일해 몇 단계 중 몇 번째인지 불명확하다. → 구현은 하드코딩하지 말고 `progress: Float` 파라미터로 받게 해서 나중에 값이 바뀌어도 컴포넌트 시그니처가 안 바뀌게 한다.
+- [ ] **Q11. 카테고리 5종 한국어 라벨 최종본.** 2-1절 임시값(`관계운/애정운/성취운/재물운/건강운`)은 기획 확정 전 추정치다. 확정되면 `strings.xml`만 교체.
+- [ ] **Q12. 성별 선택(GenderSelector) 활성 상태 색상.** Figma에 "선택됨" 상태 시안이 없다. 목적 칩과 동일하게 `primary500`+`white`로 잠정 처리했다(3-2절). 실제 시안이 나오면 교체.
+
+### 🟡 참고 사항 (차단 아님)
+
+- **N1.** CLAUDE.md 6절 "린트 미설정 TODO"는 이미 해소됨 — 루트 `build.gradle.kts`가 전 서브프로젝트에 ktlint + `nlopez-compose-rules` 적용 중. 문서 갱신 권장.
+- **N2.** `core/domain`에 테스트 의존성이 없다 — UseCase 테스트 전에 추가 필요 (3-1절에 포함됨).
+- **N3.** `TodakunTheme`이 `content()` 통과 함수다. `MaterialTheme.colorScheme` 계열을 쓰면 기본값이 나온다. 반드시 `TodakunColor.*`/`TodakunTypography.*` 직접 참조.
+- **N4.** `feature:saju-contents`가 `core:data`에 직접 의존하면 **P1**. 컨벤션 플러그인이 `core:domain`만 주입하므로 자연히 지켜지지만, 구현 중 이 경계를 넘고 싶어지면 설계가 틀렸다는 신호다.
+- **N5.** 결과 화면 배경 이미지와 별/공유/내보내기 아이콘은 에이전트가 노드 JSON으로 가져올 수 없다. 사람이 Figma에서 export해 넣어줘야 Part B2를 끝낼 수 있다.
+- **N6.** 캘린더 바텀시트 노드의 `SubText` 슬롯에 Lorem ipsum이 들어 있음 — 미사용 슬롯으로 판단해 무시.
+- **N7.** 노드 `1099:6279`에 빨간 테두리 사각형(디자이너 주석용 마커로 추정)이 있음 — 무시.
+- **N8.** 이슈 본문이 사실상 빈 템플릿이었다. 승인 시 GitHub 이슈의 "할 일" 체크리스트를 3절 기준으로 갱신 권장.
+
+---
+
+## 다음 단계
+
+1. **에이전트**: Part A 구현 (`oh-my-claudecode:executor`, Sonnet) + 유닛 테스트 — 지금 착수 가능
+2. **에이전트**: Part B1/B2 구현 (`oh-my-claudecode:designer`, Sonnet) — Part A와 병렬 가능. 배경 이미지·별 아이콘(N5)이 없으면 Part B2 최종 마무리가 막히니 그 전까지는 플레이스홀더로 진행
+3. **사람**: 배경 이미지·별/공유/내보내기 아이콘 Figma export, Q10~Q12 확정
+4. **에이전트**: 검토 (Opus) — `./gradlew test` + `./gradlew ktlintCheck` + P1 판정
diff --git a/designs/issue-108-yearly-fortune.md b/designs/issue-108-yearly-fortune.md
new file mode 100644
index 00000000..10cefd57
--- /dev/null
+++ b/designs/issue-108-yearly-fortune.md
@@ -0,0 +1,370 @@
+# 설계 문서 — #108 연도별 운세 페이지 구현
+
+> 이 문서는 **설계(Design) 단계 산출물**이며 구현 전 **사람이 검토·수정하는 게이트**다.
+> 자유롭게 수정해도 되고, 수정 후 구현 단계가 이 문서를 단일 소스로 삼아 진행한다.
+
+- **이슈**: #108 (https://github.com/YAPP-Github/28th-App-Team-2-Android/issues/108)
+- **작성**: 설계 에이전트 (Opus) / 2026-08-10
+- **상태**: 승인됨(구현 가능) — 4절 리스크 항목은 각 "기본안"대로 진행
+
+## 1. 범위
+
+이 이슈는 2개 화면(연도 선택 + 운세 결과)과 그에 필요한 전 레이어(Domain/Data/Presentation)를 다룬다.
+기존 빈 `feature:saju-contents` 모듈 안에 구현하며, 신규 모듈 생성은 불필요하다.
+
+Files Changed 10개 내외 유지를 위해 **2개 작업 단위**로 분할한다.
+
+### 작업 단위 A — Data + Domain 레이어 (선행)
+
+연도별 운세 API 연동에 필요한 DTO, DataSource, Repository, UseCase, Domain Model.
+
+### 작업 단위 B — Presentation 레이어 (A 의존)
+
+연도 선택 화면, 운세 결과 화면, ViewModel, Navigation 라우트.
+
+```
+작업 단위 A (Data+Domain)
+ |
+ v
+작업 단위 B (Presentation)
+```
+
+### 완료 기준
+
+1. 연도 선택 화면에서 2021~2031 중 하나를 선택하고 CTA 버튼을 누르면 API 호출이 발생한다.
+2. API 응답(score, title, content, fortuneCategories)이 결과 화면에 표시된다.
+3. 로딩/에러 상태가 처리된다.
+4. 뒤로가기로 연도 선택 화면에 복귀할 수 있다.
+5. 현재 연도(2026)가 기본 선택되고 "올해" 라벨이 붙는다.
+6. ViewModel 유닛 테스트가 통과한다.
+
+## 2. 설계 및 실행 계획
+
+### 2-1. API 엔드포인트 (Swagger 기준 — 단일 스펙 소스)
+
+```
+POST /api/v1/year-fortunes/{year}
+- Path: year (int32)
+- Request Body: 없음
+- Response: CommonResponse
+```
+
+**YearSelectionFortuneResponse**:
+
+| 필드 | 타입 | 설명 |
+|------|------|------|
+| `id` | UUID | 운세 레코드 ID |
+| `year` | int | 대상 연도 |
+| `score` | int | 전체 운세 점수 |
+| `title` | string | 운세 제목 |
+| `content` | string | 운세 상세 내용 |
+| `fortuneCategories` | `FortuneCategoryStarResponse[]` | 카테고리별 별점 |
+
+**FortuneCategoryStarResponse**:
+
+| 필드 | 타입 | 설명 |
+|------|------|------|
+| `fortuneCategory` | enum | RELATIONSHIP, LOVE, ACHIEVEMENT, MONEY, HEALTH |
+| `star` | int | 별점 |
+
+### 2-2. Domain Model 설계
+
+```kotlin
+// core/domain - model/fortune/YearFortune.kt
+data class YearFortune(
+ val id: String,
+ val year: Int,
+ val score: Int,
+ val title: String,
+ val content: String,
+ val categories: List,
+)
+
+data class FortuneCategoryStar(
+ val category: FortuneCategory,
+ val star: Int,
+)
+
+enum class FortuneCategory {
+ RELATIONSHIP, LOVE, ACHIEVEMENT, MONEY, HEALTH
+}
+```
+
+근거: `data class` + `val` 불변 필드 (rules/10-domain P1). DTO 노출 금지 — 별도 Domain Model 정의.
+
+### 2-3. Repository 인터페이스 (Domain)
+
+```kotlin
+// core/domain - repository/FortuneRepository.kt
+interface FortuneRepository {
+ suspend fun getYearFortune(year: Int): Result
+}
+```
+
+근거: `Result` 반환 (rules/10-domain P1). "fortune" 관심사 단위 분리.
+
+### 2-4. UseCase (Domain)
+
+```kotlin
+// core/domain - usecase/GetYearFortuneUseCase.kt
+class GetYearFortuneUseCase @Inject constructor(
+ private val fortuneRepository: FortuneRepository,
+) {
+ suspend operator fun invoke(year: Int): Result =
+ fortuneRepository.getYearFortune(year)
+}
+```
+
+근거: 동사 시작 네이밍 (rules/10-domain P2). 현재 단일 Repository 호출이므로 pass-through이지만, 향후 일별 운세 등과 조합될 가능성을 고려해 UseCase로 분리.
+
+### 2-5. Data 레이어 설계
+
+**DTO** (`core:data-remote`):
+
+```kotlin
+// YearFortuneResponse.kt
+@Serializable
+data class YearFortuneResponse(
+ val id: String,
+ val year: Int,
+ val score: Int,
+ val title: String,
+ val content: String,
+ val fortuneCategories: List,
+)
+
+@Serializable
+data class FortuneCategoryStarResponse(
+ val fortuneCategory: String,
+ val star: Int,
+)
+```
+
+`~Response` 네이밍 (rules/20-data P2). Raw 서버 형태 유지.
+
+**Mapper** (DTO -> Domain, `core:data-remote` 내 확장 함수):
+
+```kotlin
+fun YearFortuneResponse.toDomain(): YearFortune = YearFortune(
+ id = id,
+ year = year,
+ score = score,
+ title = title,
+ content = content,
+ categories = fortuneCategories.map { it.toDomain() },
+)
+
+fun FortuneCategoryStarResponse.toDomain(): FortuneCategoryStar = FortuneCategoryStar(
+ category = FortuneCategory.valueOf(fortuneCategory),
+ star = star,
+)
+```
+
+**DataSource 인터페이스** (`core:data`):
+
+```kotlin
+interface RemoteFortuneDataSource {
+ suspend fun postYearFortune(year: Int): YearFortune
+}
+```
+
+근거: REST 엔드포인트 1:1 대응 (rules/20-data P2). 동사 접두사 `post` = HTTP POST.
+DataSource는 예외를 삼키지 않고 throw (rules/20-data P1). Domain Model 반환.
+
+**DataSource 구현** (`core:data-remote`):
+
+```kotlin
+class RemoteFortuneDataSourceImpl @Inject constructor(
+ private val client: Lazy,
+) : RemoteFortuneDataSource {
+ override suspend fun postYearFortune(year: Int): YearFortune =
+ client.get()
+ .post("api/v1/year-fortunes/$year")
+ .bodyNotNull()
+ .toDomain()
+
+}
+```
+
+기존 `RemoteAuthDataSourceImpl` 패턴 재사용: `Lazy`, `bodyNotNull()`.
+
+**RepositoryImpl** (`core:data`):
+
+```kotlin
+class FortuneRepositoryImpl @Inject constructor(
+ private val remoteFortuneDataSource: RemoteFortuneDataSource,
+) : FortuneRepository {
+ override suspend fun getYearFortune(year: Int): Result =
+ runCatchingCancellable {
+ remoteFortuneDataSource.postYearFortune(year)
+ }
+}
+```
+
+근거: `runCatchingCancellable` + `Result` (rules/20-data P1). 기존 `AuthRepositoryImpl` 패턴과 동일.
+
+**DI 바인딩**: 기존 `RepositoryModule`과 `RemoteDataSourceModule`에 `@Binds` 추가.
+
+### 2-6. Presentation 레이어 설계
+
+**Figma 노드 매핑** (node-id=1100:7469 "연도별 운세 filled"):
+
+| Figma 요소 | 디자인시스템 컴포넌트 | 토큰 |
+|------------|---------------------|------|
+| 상단 헤더 (뒤로가기 + "연도별 운세") | `TodakunSubHeader(title="연도별 운세")` | body2SemiBold, gray925 |
+| 제목 "어느 해가 궁금하시나요?" | `Text` | heading3Bold, gray975 |
+| 연도 선택 박스 (2x grid) | `TodakunSelectBox` | body1Medium, primary50/600/700 (selected), coolGray300/800 (default) |
+| 하단 CTA 버튼 | `PrimaryButton(size=Large)` "무료로 연도별 운세 보기" | primary600, white, body2SemiBold |
+| 하단 그라데이션 | `Brush.verticalGradient(transparent -> white)` | - |
+
+**node-id=2002:22541 "연도별 운세 - 상세"** (결과 화면 — TalkToFigma로 재조회 완료):
+
+다크 배경(`#00010b`) 위 카드형 레이아웃. 위에서부터:
+
+| Figma 요소 | 내용/데이터 소스 | 비고 |
+|------------|------------------|------|
+| Header | 뒤로가기 + "연도별 운세 결과" + 공유 아이콘(우측) | 공유 아이콘 탭 → 아래 공유 바텀시트 |
+| 연도 칩 | "2026 병오년(丙午年)" | `year`(API) + **육십갑자 계산값**(클라이언트, API에 없음 — 리스크 참조). Figma 목업 원문 "정미년"은 2027년 값으로 오기이며, 검토 단계에서 `(year-4)%60` 계산이 맞음을 확인함 |
+| 타이틀 | "2026년 토닥이님의 운세" | `year` 조합 문자열, 템플릿 |
+| 원형 점수 배지 | "85" + "연간 종합 점수" | `score`(API) |
+| 한줄 헤드라인 | "새로운 도전이 결실을 맺는 해예요." | `title`(API) |
+| 카테고리 별점 3개 | 성취운/재물운/관계운 각 별 3개(칠해진 개수 = star) | `fortuneCategories`(API) — **Figma는 3개만 노출, API enum은 5개(RELATIONSHIP/LOVE/ACHIEVEMENT/MONEY/HEALTH) — 리스크 참조** |
+| "종합 총평" 카드 | 긴 본문 문단 | `content`(API) |
+| 하단 CTA | "토닥이에게 더 물어보기" 버튼 + 말풍선 칩("시간대는 언제가 좋아?") | **`feature:chat` 연동 — 리스크 참조** |
+
+**node-id=2266:21869 "연도별 운세 - 공유"**: 별도 화면이 아니라 **위 결과 화면 위에 반투명 딤(40% 블랙) + 공유 바텀시트가 열린 상태**다. 신규 화면이 아니라 `ShareBottomSheet` 컴포넌트로 설계한다.
+
+| Figma 요소 | 내용 | 비고 |
+|------------|------|------|
+| 타이틀 | "운세 공유하기" | |
+| 버튼 1 | "카카오톡 공유" (노란색) | **Kakao Share/Link SDK 미설치 — 리스크 참조** (현재 `kakao.user`만 있음) |
+| 버튼 2 | "URL 복사" | 클립보드 복사, 신규 의존성 불필요 |
+| 버튼 3 | "취소" | 바텀시트 닫기 |
+
+> 빨간 안내문구("본문 내용을 입력해주세요.")는 Kakao Link 에디터의 미입력 placeholder로 보이며 실제 노출 문구가 아닐 가능성이 있다 — 구현 시 무시하고 진행.
+
+**ViewModel (MVI + Orbit)**:
+
+```
+// State
+data class YearFortuneState(
+ val years: List = (2021..2031).toList(),
+ val selectedYear: Int = currentYear,
+ val currentYear: Int = currentYear,
+ val fortuneResult: YearFortune? = null,
+ val isLoading: Boolean = false,
+ val error: String? = null,
+)
+
+// SideEffect
+sealed interface YearFortuneSideEffect {
+ data class NavigateToResult(val year: Int) : YearFortuneSideEffect
+ data object NavigateBack : YearFortuneSideEffect
+}
+```
+
+ViewModel은 `ContainerHost` 위임 (`by(Delegate)`) 사용 (rules/30-presentation).
+비즈니스 로직은 `GetYearFortuneUseCase`에 위임 (rules/30-presentation P2).
+
+**데이터 흐름**:
+
+```
+[YearSelectionScreen]
+ 사용자: 연도 선택 -> Intent: selectYear(year)
+ 사용자: CTA 클릭 -> Intent: requestFortune()
+ -> ViewModel: GetYearFortuneUseCase(selectedYear)
+ -> FortuneRepository.getYearFortune(year)
+ -> RemoteFortuneDataSource.postYearFortune(year)
+ -> POST /api/v1/year-fortunes/{year}
+ -> Result 성공: SideEffect NavigateToResult
+ -> Result 실패: State.error 업데이트
+
+[YearFortuneResultScreen]
+ ViewModel state에서 fortuneResult 읽어서 표시
+ 뒤로가기 -> SideEffect NavigateBack
+```
+
+**Navigation**: `core:navigation`에 라우트 정의. Navigation 3 패턴 사용.
+
+### 2-7. 테스트 계획
+
+| 대상 | 테스트 | 모듈 |
+|------|--------|------|
+| `FortuneRepositoryImpl` | 성공/실패 시 `Result` 래핑 검증 | `core:data` |
+| `GetYearFortuneUseCase` | Repository 위임 검증 (pass-through이므로 간단) | `core:domain` |
+| `YearFortuneViewModel` | 연도 선택 상태 변경, API 호출 성공/실패 시 상태 전이, SideEffect 발행 | `feature:saju-contents` |
+| `RemoteFortuneDataSourceImpl` | Ktor MockEngine으로 API 응답 파싱 검증 | `core:data-remote` |
+
+## 3. 파일 변경 계획 (구현 체크리스트)
+
+### 작업 단위 A — Data + Domain
+
+```
+core/domain/src/main/java/com/kikidan/domain/
+ model/fortune/
+ - [ ] YearFortune.kt — Domain Model (data class)
+ - [ ] FortuneCategory.kt — enum
+ - [ ] FortuneCategoryStar.kt — data class
+ repository/
+ - [ ] FortuneRepository.kt — Repository 인터페이스
+ usecase/
+ - [ ] GetYearFortuneUseCase.kt — UseCase
+
+core/data/src/main/java/com/kikidan/data/
+ datasource/
+ - [ ] RemoteFortuneDataSource.kt — DataSource 인터페이스
+ repository/
+ - [ ] FortuneRepositoryImpl.kt — RepositoryImpl
+ di/
+ - [ ] RepositoryModule.kt — @Binds 추가 (기존 파일 수정)
+
+core/data-remote/src/main/java/com/kikidan/data_remote/
+ dto/fortune/
+ - [ ] YearFortuneResponse.kt — DTO + toDomain() Mapper
+ datasource/
+ - [ ] RemoteFortuneDataSourceImpl.kt — DataSource 구현
+ di/
+ - [ ] RemoteDataSourceModule.kt — @Binds 추가 (기존 파일 수정)
+```
+
+### 작업 단위 B — Presentation
+
+```
+feature/saju-contents/src/main/java/com/kikidan/sajucontents/
+ yearfortune/
+ - [ ] YearFortuneViewModel.kt — ViewModel (Orbit MVI)
+ - [ ] YearFortuneState.kt — State + SideEffect 정의
+ - [ ] YearSelectionScreen.kt — 연도 선택 화면 Composable
+ - [ ] YearFortuneResultScreen.kt — 운세 결과 화면 Composable (헤더 공유 아이콘 포함)
+ - [ ] ShareBottomSheet.kt — 공유 바텀시트(카카오톡 공유/URL 복사/취소)
+
+core/domain/src/main/java/com/kikidan/domain/util/
+ - [x] YearGanjiFormatter.kt — 연도 → 육십갑자 문자열 변환 (예: 2026 → "병오년(丙午年)"), 순수 함수 — 구현 완료, 검토 단계에서 계산값 검증됨
+
+core/navigation/src/main/java/com/kikidan/navigation/
+ - [ ] YearFortuneRoute.kt — Navigation 3 라우트 정의 (또는 기존 라우트 파일에 추가)
+```
+
+### 테스트 파일
+
+```
+core/data/src/test/.../repository/
+ - [ ] FortuneRepositoryImplTest.kt
+
+core/data-remote/src/test/.../datasource/
+ - [ ] RemoteFortuneDataSourceImplTest.kt
+
+feature/saju-contents/src/test/.../yearfortune/
+ - [ ] YearFortuneViewModelTest.kt
+```
+
+## 4. 리스크 / 미해결 질문 (사람 확인 필요)
+
+- [x] **육십갑자 계산**: `YearGanjiFormatter`(`(year-4)%60` 순환식)로 구현·검토 완료. 2026 → "병오년(丙午年)"이 맞는 값이며, 최초 Figma 목업/설계문서에 적혀 있던 "정미년(丁未年)"(2027년 값)은 오기였음 — 문서 정정함. 서버가 이 문자열을 내려줄 계획이 있다면 알려달라(현재는 클라이언트 계산 유지).
+- [ ] **카테고리 3개 vs 5개**: Figma는 성취운/재물운/관계운 3개만 보여주지만 Swagger enum은 5개(RELATIONSHIP/LOVE/ACHIEVEMENT/MONEY/HEALTH)다. **기본안**: API가 내려주는 `fortuneCategories` 리스트를 그대로 순회해 렌더링(하드코딩 3개 금지) — 연도별 운세는 실제로 3개만 오는 것으로 추정하고 구현하되, 5개가 오면 자동으로 5개가 표시되게 한다. 의도적으로 3개만 표시해야 한다면 알려달라.
+- [ ] **`feature:chat` 의존성 없음**: "토닥이에게 더 물어보기" 버튼은 채팅 기능으로 이동하는 것으로 보이나, 현재 base 브랜치(`feat/105-saju-contents`)엔 `feature:chat` 모듈이 없다(다른 브랜치 `feat/80-history`에만 존재). **기본안**: 이번 이슈에서는 버튼을 배치하고 `core:navigation`에 라우트 자리만 만들되, 실제 이동은 TODO로 남기거나 no-op으로 둔다. 채팅 기능과의 통합 시점을 알려달라.
+- [ ] **카카오톡 공유 SDK 미설치**: `feature/auth`엔 로그인용 `kakao.user`만 있고 공유용 Kakao SDK(`v2-share`/`v2-talk`)는 없다. 신규 의존성 추가가 필요하므로 **이번 이슈 범위에 카카오톡 공유를 포함할지, URL 복사만 먼저 구현할지** 확인 필요. (URL 복사·취소는 신규 의존성 없이 구현 가능)
+- [ ] **연도 범위(2021~2031) 하드코딩 여부**: Figma에는 2021~2031이 표시되어 있다. 서버에 지원 연도 목록 API가 없으므로 클라이언트에서 하드코딩할 예정이다. 범위가 달라져야 하면 알려달라.
+- [ ] **"무료로" 문구**: CTA 버튼 텍스트가 "무료로 연도별 운세 보기"인데, 유료 전환 계획이 있는지? 있다면 결제 플로우 연동 범위가 달라진다. 현재는 무조건 무료 호출로 구현한다.
+- [ ] **FortuneCategory enum 매핑 안전성**: 서버가 새 카테고리를 추가할 경우 `valueOf`가 실패한다. `UNKNOWN` fallback을 넣을지 여부.
+- [ ] **Navigation 패턴**: `core:navigation`에 아직 라우트 파일이 없다 (빈 모듈). 기존 앱에서 사용 중인 Navigation 3 패턴이 있다면 그것을 따르겠지만, 현재 워크트리에는 참조할 패턴이 없다. Navigation 3 기본 패턴으로 진행해도 되는지?
diff --git a/feature/mypage/src/main/java/com/kikidan/mypage/mansaeryeok/model/TwelveSinsal.kt b/feature/mypage/src/main/java/com/kikidan/mypage/mansaeryeok/model/TwelveSinsal.kt
index 9c3f09a1..4310688d 100644
--- a/feature/mypage/src/main/java/com/kikidan/mypage/mansaeryeok/model/TwelveSinsal.kt
+++ b/feature/mypage/src/main/java/com/kikidan/mypage/mansaeryeok/model/TwelveSinsal.kt
@@ -15,7 +15,7 @@ enum class TwelveSinsal(
MANGSINSAL(R.string.mansaeryeok_twelve_sinsal_mangsinsal),
JANGSEONGSAL(R.string.mansaeryeok_twelve_sinsal_jangseongsal),
BANANSAL(R.string.mansaeryeok_twelve_sinsal_banansal),
- YEOKMASAL(R.string.mansaeryeok_twelve_sinsal_yeokmasal),
+ YEONGMASAL(R.string.mansaeryeok_twelve_sinsal_yeokmasal),
YUKHAESAL(R.string.mansaeryeok_twelve_sinsal_yukhaesal),
HWAGAESAL(R.string.mansaeryeok_twelve_sinsal_hwagaesal),
}
diff --git a/feature/mypage/src/main/java/com/kikidan/mypage/mansaeryeok/model/TwelveUnseong.kt b/feature/mypage/src/main/java/com/kikidan/mypage/mansaeryeok/model/TwelveUnseong.kt
index 9530c455..5007acc3 100644
--- a/feature/mypage/src/main/java/com/kikidan/mypage/mansaeryeok/model/TwelveUnseong.kt
+++ b/feature/mypage/src/main/java/com/kikidan/mypage/mansaeryeok/model/TwelveUnseong.kt
@@ -7,7 +7,7 @@ enum class TwelveUnseong(
@param:StringRes val labelRes: Int,
) {
JANGSAENG(R.string.mansaeryeok_twelve_unseong_jangsaeng),
- MOGYOK(R.string.mansaeryeok_twelve_unseong_mogyok),
+ MOKYOK(R.string.mansaeryeok_twelve_unseong_mogyok),
GWANDAE(R.string.mansaeryeok_twelve_unseong_gwandae),
GEONROK(R.string.mansaeryeok_twelve_unseong_geonrok),
JEWANG(R.string.mansaeryeok_twelve_unseong_jewang),
diff --git a/feature/saju-contents/build.gradle.kts b/feature/saju-contents/build.gradle.kts
new file mode 100644
index 00000000..3765bdd3
--- /dev/null
+++ b/feature/saju-contents/build.gradle.kts
@@ -0,0 +1,45 @@
+import java.util.Properties
+import kotlin.apply
+
+plugins {
+ id("todakun.feature")
+}
+
+val localProperty =
+ Properties().apply {
+ val f = rootProject.file("local.properties")
+ if (f.exists()) f.inputStream().use { load(it) }
+ }
+
+android {
+ namespace = "com.kikidan.sajucontents"
+
+ buildTypes {
+ debug {
+ // TODO CI 통과를 위해 공백을 넣음. 추후 CD 설정 시 재설정
+ val appLinkHost = localProperty.getProperty("APP_LINK_HOST_DEV") ?: ""
+ buildConfigField("String", "APP_LINK_HOST", "\"https://${appLinkHost}\"")
+ }
+ release {
+ // TODO CI 통과를 위해 공백을 넣음. 추후 CD 설정 시 재설정
+ val appLinkHost = localProperty.getProperty("APP_LINK_HOST") ?: ""
+ buildConfigField("String", "APP_LINK_HOST", "\"https://${appLinkHost}\"")
+ }
+ }
+
+ buildFeatures {
+ buildConfig = true
+ }
+}
+
+dependencies {
+ testImplementation(libs.kotlinx.coroutines.test)
+ implementation(libs.kakao.share)
+ implementation(libs.kizitonwose.calendar.compose)
+ implementation(libs.haze)
+}
+
+dependencies {
+ // orbit-test는 kotlinx-coroutines-test API를 노출하지만, 버전 충돌 방지를 위해 명시 선언한다.
+ testImplementation(libs.kotlinx.coroutines.test)
+}
diff --git a/feature/saju-contents/src/main/AndroidManifest.xml b/feature/saju-contents/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..b2d3ea12
--- /dev/null
+++ b/feature/saju-contents/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/CompatibilityInputRoute.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/CompatibilityInputRoute.kt
new file mode 100644
index 00000000..6d8ad520
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/CompatibilityInputRoute.kt
@@ -0,0 +1,64 @@
+package com.kikidan.sajucontents
+
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.kikidan.sajucontents.model.CompatibilityEntrySideEffect
+import com.kikidan.sajucontents.screen.CompatibilityInputScreen
+import org.orbitmvi.orbit.compose.collectAsState
+import org.orbitmvi.orbit.compose.collectSideEffect
+
+@Composable
+fun CompatibilityEntryRoute(
+ snackbarHostState: SnackbarHostState,
+ onNavigateBack: () -> Unit,
+ onNavigateMyPage: () -> Unit,
+ onNavigateToPartnerForm: () -> Unit,
+ onNavigateToResult: (compatibilityId: String, partnerLinkId: String) -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: CompatibilityInputViewModel = hiltViewModel(),
+) {
+ val state by viewModel.collectAsState()
+ val loadErrorMessage = stringResource(R.string.compatibility_load_error)
+ val createErrorMessage = stringResource(R.string.compatibility_create_error)
+
+ LaunchedEffect(Unit) {
+ viewModel.loadMyInfo()
+ }
+
+ viewModel.collectSideEffect { effect ->
+ when (effect) {
+ CompatibilityEntrySideEffect.ShowLoadError -> {
+ snackbarHostState.showSnackbar(loadErrorMessage)
+ }
+
+ CompatibilityEntrySideEffect.ShowCreateError -> {
+ snackbarHostState.showSnackbar(createErrorMessage)
+ }
+
+ CompatibilityEntrySideEffect.NavigateToPartnerForm -> {
+ onNavigateToPartnerForm()
+ }
+
+ is CompatibilityEntrySideEffect.NavigateToResult -> {
+ onNavigateToResult(effect.compatibilityId, effect.partnerLinkId)
+ }
+ }
+ }
+
+ CompatibilityInputScreen(
+ state = state,
+ onBackClick = onNavigateBack,
+ onChangeMyInfo = onNavigateMyPage,
+ onOpenPartnerPicker = viewModel::openPartnerPicker,
+ onDismissPartnerPicker = viewModel::dismissPartnerPicker,
+ onAddNewPartner = viewModel::addNewPartner,
+ onSelectPartner = viewModel::selectPartner,
+ onCheckCompatibilityClick = viewModel::checkCompatibility,
+ modifier = modifier,
+ )
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/CompatibilityInputViewModel.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/CompatibilityInputViewModel.kt
new file mode 100644
index 00000000..d0712ec5
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/CompatibilityInputViewModel.kt
@@ -0,0 +1,149 @@
+package com.kikidan.sajucontents
+
+import androidx.lifecycle.ViewModel
+import com.kikidan.domain.model.saju.SajuPillarType
+import com.kikidan.domain.usecase.CreateCompatibilityUseCase
+import com.kikidan.domain.usecase.mypage.GetMansaeryeokDetailUseCase
+import com.kikidan.domain.usecase.saju.GetPartnerMansaeryeokDetailUseCase
+import com.kikidan.domain.usecase.saju.GetPartnerSajuListUseCase
+import com.kikidan.sajucontents.model.CompatibilityEntrySideEffect
+import com.kikidan.sajucontents.model.CompatibilityInputState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.collections.immutable.toPersistentList
+import kotlinx.collections.immutable.toPersistentSet
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import org.orbitmvi.orbit.Container
+import org.orbitmvi.orbit.ContainerHost
+import org.orbitmvi.orbit.viewmodel.container
+import javax.inject.Inject
+import kotlin.fold
+
+@HiltViewModel
+class CompatibilityInputViewModel
+ @Inject
+ constructor(
+ private val getMansaeryeokDetail: GetMansaeryeokDetailUseCase,
+ private val getPartnerSajuList: GetPartnerSajuListUseCase,
+ private val getPartnerMansaeryeokDetail: GetPartnerMansaeryeokDetailUseCase,
+ private val createCompatibility: CreateCompatibilityUseCase,
+ ) : ViewModel(),
+ ContainerHost {
+ override val container: Container =
+ container(CompatibilityInputState())
+
+ fun loadMyInfo() =
+ intent {
+ reduce { state.copy(isLoading = true) }
+ coroutineScope {
+ val mansaeryeokDetailDeferred = async { getMansaeryeokDetail() }
+ val partnerListDeferred = async { getPartnerSajuList() }
+ val mansaeryeokDetail = mansaeryeokDetailDeferred.await()
+ val partnerList = partnerListDeferred.await()
+
+ runCatching {
+ val detail = mansaeryeokDetail.getOrThrow()
+ val list = partnerList.getOrThrow()
+ detail to list
+ }.fold(
+ onSuccess = { (detail, list) ->
+ reduce {
+ state.copy(
+ isLoading = false,
+ myUser = detail.user,
+ myPillars =
+ PillarDisplayOrder
+ .mapNotNull { type ->
+ detail.chart.pillars.firstOrNull {
+ it.pillarType ==
+ type
+ }
+ }.toPersistentList(),
+ partnerPicker =
+ state.partnerPicker.copy(
+ isLoading = false,
+ partners = list.toPersistentList(),
+ ),
+ )
+ }
+
+ // 상대방 추가 후 되돌아왔을 때 선택이 되어있게끔
+ if (list.size == 1) {
+ selectPartner(list.first().linkId)
+ }
+ },
+ onFailure = {
+ reduce { state.copy(isLoading = false) }
+ postSideEffect(CompatibilityEntrySideEffect.ShowLoadError)
+ },
+ )
+ }
+ }
+
+ fun openPartnerPicker() =
+ intent {
+ if (state.partnerPicker.isLoading || state.isLoading) return@intent
+ if (state.partnerPicker.partners.isEmpty()) {
+ postSideEffect(CompatibilityEntrySideEffect.NavigateToPartnerForm)
+ return@intent
+ }
+ reduce { state.copy(partnerPicker = state.partnerPicker.copy(isVisible = true)) }
+ }
+
+ fun dismissPartnerPicker() =
+ intent {
+ reduce { state.copy(partnerPicker = state.partnerPicker.copy(isVisible = false)) }
+ }
+
+ fun addNewPartner() =
+ intent {
+ reduce { state.copy(partnerPicker = state.partnerPicker.copy(isVisible = false)) }
+ postSideEffect(CompatibilityEntrySideEffect.NavigateToPartnerForm)
+ }
+
+ fun selectPartner(linkId: String) =
+ intent {
+ getPartnerMansaeryeokDetail(linkId)
+ .onSuccess { detail ->
+ reduce {
+ state.copy(
+ selectedPartner = detail.partner,
+ selectedPartnerPillars =
+ PillarDisplayOrder
+ .mapNotNull { type ->
+ detail.chart.pillars.firstOrNull {
+ it.pillarType ==
+ type
+ }
+ }.toPersistentList(),
+ partnerPicker = state.partnerPicker.copy(isVisible = false),
+ )
+ }
+ }.onFailure {
+ reduce { state.copy(partnerPicker = state.partnerPicker.copy(isVisible = false)) }
+ postSideEffect(CompatibilityEntrySideEffect.ShowLoadError)
+ }
+ }
+
+ fun checkCompatibility() =
+ intent {
+ val partnerLinkId = state.selectedPartner?.linkId ?: return@intent
+ if (state.isCreating) return@intent
+ reduce { state.copy(isCreating = true) }
+ createCompatibility(partnerLinkId)
+ .onSuccess { compatibility ->
+ reduce { state.copy(isCreating = false) }
+ postSideEffect(
+ CompatibilityEntrySideEffect.NavigateToResult(compatibility.id, partnerLinkId),
+ )
+ }.onFailure {
+ reduce { state.copy(isCreating = false) }
+ postSideEffect(CompatibilityEntrySideEffect.ShowCreateError)
+ }
+ }
+
+ companion object {
+ private val PillarDisplayOrder =
+ listOf(SajuPillarType.HOUR, SajuPillarType.DAY, SajuPillarType.MONTH, SajuPillarType.YEAR)
+ }
+ }
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/CompatibilityPartnerFormViewModel.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/CompatibilityPartnerFormViewModel.kt
new file mode 100644
index 00000000..58937fc0
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/CompatibilityPartnerFormViewModel.kt
@@ -0,0 +1,70 @@
+package com.kikidan.sajucontents
+
+import androidx.lifecycle.ViewModel
+import com.kikidan.domain.model.saju.PartnerSajuInput
+import com.kikidan.domain.model.user.Birth
+import com.kikidan.domain.model.user.BirthTime
+import com.kikidan.domain.model.user.DateType
+import com.kikidan.domain.model.user.Gender
+import com.kikidan.domain.usecase.saju.RegisterPartnerSajuUseCase
+import com.kikidan.sajucontents.model.CompatibilityPartnerFormSideEffect
+import com.kikidan.sajucontents.model.CompatibilityPartnerFormState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import org.orbitmvi.orbit.Container
+import org.orbitmvi.orbit.ContainerHost
+import org.orbitmvi.orbit.viewmodel.container
+import java.time.LocalDate
+import javax.inject.Inject
+
+private const val MAX_NAME_LENGTH = 10
+
+@HiltViewModel
+class CompatibilityPartnerFormViewModel
+ @Inject
+ constructor(
+ private val registerPartnerSajuUseCase: RegisterPartnerSajuUseCase,
+ ) : ViewModel(),
+ ContainerHost {
+ override val container: Container =
+ container(CompatibilityPartnerFormState())
+
+ fun updateName(name: String) =
+ intent {
+ val errorRes =
+ if (name.length > MAX_NAME_LENGTH) R.string.compatibility_partner_form_name_error else null
+ reduce { state.copy(name = name, nameErrorMessageRes = errorRes) }
+ }
+
+ fun selectGender(gender: Gender) = intent { reduce { state.copy(gender = gender) } }
+
+ fun selectDateType(dateType: DateType) = intent { reduce { state.copy(dateType = dateType) } }
+
+ fun updateBirthDate(birthDate: LocalDate?) = intent { reduce { state.copy(birthDate = birthDate) } }
+
+ fun updateBirthTime(birthTime: BirthTime) = intent { reduce { state.copy(birthTime = birthTime) } }
+
+ fun selectRelationshipType(code: String) = intent { reduce { state.copy(relationshipTypeCode = code) } }
+
+ fun save() =
+ intent {
+ val birthDate = state.birthDate ?: return@intent
+ if (!state.isSaveEnabled) return@intent
+ reduce { state.copy(isSaving = true) }
+
+ val input =
+ PartnerSajuInput(
+ name = state.name,
+ gender = state.gender,
+ relationshipTypeCode = state.relationshipTypeCode,
+ birth = Birth(dateType = state.dateType, date = birthDate, time = state.birthTime),
+ )
+ registerPartnerSajuUseCase(input)
+ .onSuccess {
+ reduce { state.copy(isSaving = false) }
+ postSideEffect(CompatibilityPartnerFormSideEffect.NavigateBack)
+ }.onFailure {
+ reduce { state.copy(isSaving = false) }
+ postSideEffect(CompatibilityPartnerFormSideEffect.ShowSaveError)
+ }
+ }
+ }
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/CompatibilityResultRoute.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/CompatibilityResultRoute.kt
new file mode 100644
index 00000000..090e3fc3
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/CompatibilityResultRoute.kt
@@ -0,0 +1,55 @@
+package com.kikidan.sajucontents
+
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.kikidan.sajucontents.model.CompatibilityResultSideEffect
+import com.kikidan.sajucontents.screen.CompatibilityResultScreen
+import org.orbitmvi.orbit.compose.collectAsState
+import org.orbitmvi.orbit.compose.collectSideEffect
+import com.kikidan.designsystem.R as DesignSystemR
+
+// id를 직접 전달받아 스스로 데이터를 불러온다. 공유 딥링크로 바로 진입할 수 있도록
+// 궁합 입력 화면과 별개인 CompatibilityResultViewModel을 사용한다.
+@Composable
+fun CompatibilityResultRoute(
+ compatibilityId: String,
+ partnerLinkId: String?,
+ snackbarHostState: SnackbarHostState,
+ onNavigateBack: () -> Unit,
+ onAskTodakClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: CompatibilityResultViewModel = hiltViewModel(),
+) {
+ val state by viewModel.collectAsState()
+ val loadErrorMessage = stringResource(R.string.compatibility_load_error)
+ val shareErrorMessage = stringResource(DesignSystemR.string.fortune_share_kakao_error)
+ val urlCopiedMessage = stringResource(DesignSystemR.string.fortune_share_url_copied)
+
+ LaunchedEffect(compatibilityId, partnerLinkId) {
+ viewModel.load(compatibilityId, partnerLinkId)
+ }
+
+ viewModel.collectSideEffect { effect ->
+ when (effect) {
+ CompatibilityResultSideEffect.ShowError -> snackbarHostState.showSnackbar(loadErrorMessage)
+ CompatibilityResultSideEffect.ShowShareError -> snackbarHostState.showSnackbar(shareErrorMessage)
+ CompatibilityResultSideEffect.ShowUrlCopied -> snackbarHostState.showSnackbar(urlCopiedMessage)
+ }
+ }
+
+ CompatibilityResultScreen(
+ state = state,
+ onBackClick = onNavigateBack,
+ onShareClick = viewModel::showShareDialog,
+ onShareDismiss = viewModel::hideShareDialog,
+ onKakaoShareFail = viewModel::notifyShareUnavailable,
+ onUrlCopy = viewModel::notifyUrlCopied,
+ onAskTodakClick = onAskTodakClick,
+ modifier = modifier,
+ )
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/CompatibilityResultViewModel.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/CompatibilityResultViewModel.kt
new file mode 100644
index 00000000..d3619a48
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/CompatibilityResultViewModel.kt
@@ -0,0 +1,94 @@
+package com.kikidan.sajucontents
+
+import androidx.lifecycle.ViewModel
+import com.kikidan.domain.usecase.GetCompatibilityUseCase
+import com.kikidan.domain.usecase.saju.GetPartnerSajuChartDetailUseCase
+import com.kikidan.domain.usecase.saju.GetSajuChartDetailUseCase
+import com.kikidan.sajucontents.model.CompatibilityResultSideEffect
+import com.kikidan.sajucontents.model.CompatibilityResultState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toPersistentList
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import org.orbitmvi.orbit.Container
+import org.orbitmvi.orbit.ContainerHost
+import org.orbitmvi.orbit.syntax.Syntax
+import org.orbitmvi.orbit.viewmodel.container
+import javax.inject.Inject
+
+// id를 직접 전달받아 스스로 데이터를 불러온다. 공유 딥링크로 결과 화면에 바로 진입할 수 있도록
+// 궁합 입력 화면(CompatibilityEntryViewModel)과 완전히 분리된 인스턴스로 존재한다.
+@HiltViewModel
+class CompatibilityResultViewModel
+ @Inject
+ constructor(
+ private val getCompatibility: GetCompatibilityUseCase,
+ private val getSajuChartDetail: GetSajuChartDetailUseCase,
+ private val getPartnerSajuChartDetail: GetPartnerSajuChartDetailUseCase,
+ ) : ViewModel(),
+ ContainerHost {
+ override val container: Container =
+ container(CompatibilityResultState.Loading)
+
+ fun load(
+ compatibilityId: String,
+ partnerLinkId: String?,
+ ) = intent {
+ reduce { CompatibilityResultState.Loading }
+
+ getCompatibility(compatibilityId).fold(
+ onSuccess = { result ->
+ val pillars = loadSajuStructure(partnerLinkId)
+ reduce {
+ CompatibilityResultState.Success(
+ compatibilityResult = result,
+ myPillars = pillars?.first?.toPersistentList() ?: persistentListOf(),
+ partnerPillars = pillars?.second?.toPersistentList() ?: persistentListOf(),
+ )
+ }
+ },
+ onFailure = {
+ reduce { CompatibilityResultState.Failure }
+ postSideEffect(CompatibilityResultSideEffect.ShowError)
+ },
+ )
+ }
+
+ private suspend fun loadSajuStructure(partnerLinkId: String?) =
+ coroutineScope {
+ if (partnerLinkId == null) return@coroutineScope null
+ val myDeferred = async { getSajuChartDetail() }
+ val partnerDeferred = async { getPartnerSajuChartDetail(partnerLinkId) }
+ val my = myDeferred.await().getOrNull() ?: return@coroutineScope null
+ val partner = partnerDeferred.await().getOrNull() ?: return@coroutineScope null
+ my.pillars to partner.pillars
+ }
+
+ fun showShareDialog() = intent { reduceShareDialogVisible(true) }
+
+ fun hideShareDialog() = intent { reduceShareDialogVisible(false) }
+
+ fun notifyShareUnavailable() =
+ intent {
+ reduceShareDialogVisible(false)
+ postSideEffect(CompatibilityResultSideEffect.ShowShareError)
+ }
+
+ fun notifyUrlCopied() =
+ intent {
+ reduceShareDialogVisible(false)
+ postSideEffect(CompatibilityResultSideEffect.ShowUrlCopied)
+ }
+
+ private suspend fun Syntax.reduceShareDialogVisible(
+ isVisible: Boolean,
+ ) = reduce {
+ val current = state
+ if (current is CompatibilityResultState.Success) {
+ current.copy(isShareDialogVisible = isVisible)
+ } else {
+ current
+ }
+ }
+ }
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/DateFortuneInputRoute.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/DateFortuneInputRoute.kt
new file mode 100644
index 00000000..33da283b
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/DateFortuneInputRoute.kt
@@ -0,0 +1,62 @@
+package com.kikidan.sajucontents
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.kikidan.designsystem.R
+import com.kikidan.sajucontents.model.DateFortuneSideEffect
+import com.kikidan.sajucontents.screen.DateFortuneInputScreen
+import kotlinx.collections.immutable.ImmutableList
+import org.orbitmvi.orbit.compose.collectAsState
+import org.orbitmvi.orbit.compose.collectSideEffect
+
+@Composable
+fun DateFortuneInputRoute(
+ snackbarHostState: SnackbarHostState,
+ onNavigateBack: () -> Unit,
+ onNavigateToResult: (List) -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: DateFortuneViewModel = hiltViewModel(),
+) {
+ val state by viewModel.collectAsState()
+ val maxDateSelectionMessage = stringResource(R.string.date_fortune_max_dates_toast)
+ val submitErrorMessage = stringResource(R.string.date_fortune_submit_error)
+
+ viewModel.collectSideEffect { effect ->
+ when (effect) {
+ is DateFortuneSideEffect.ShowToast -> {
+ snackbarHostState.showSnackbar(maxDateSelectionMessage)
+ }
+
+ is DateFortuneSideEffect.ShowError -> {
+ snackbarHostState.showSnackbar(submitErrorMessage)
+ }
+
+ is DateFortuneSideEffect.NavigateToResult -> {
+ onNavigateToResult(effect.id)
+ }
+
+ DateFortuneSideEffect.NavigateBack -> {
+ onNavigateBack()
+ }
+ }
+ }
+
+ Box(modifier = modifier.fillMaxSize()) {
+ DateFortuneInputScreen(
+ state = state,
+ onPurposeSelect = viewModel::selectPurpose,
+ onOpenDateSheet = viewModel::openDateSheet,
+ onCloseDateSheet = viewModel::closeDateSheet,
+ onDateToggle = viewModel::toggleDate,
+ onDateRemove = viewModel::removeDate,
+ onReset = viewModel::reset,
+ onSubmit = viewModel::submit,
+ )
+ }
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/DateFortuneResultRoute.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/DateFortuneResultRoute.kt
new file mode 100644
index 00000000..3f37ea65
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/DateFortuneResultRoute.kt
@@ -0,0 +1,99 @@
+package com.kikidan.sajucontents
+
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.provider.CalendarContract
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.kikidan.designsystem.R
+import com.kikidan.domain.model.dayfortune.DayFortune
+import com.kikidan.sajucontents.model.DateFortuneResultSideEffect
+import com.kikidan.sajucontents.screen.DateFortuneResultScreen
+import kotlinx.coroutines.launch
+import org.orbitmvi.orbit.compose.collectAsState
+import org.orbitmvi.orbit.compose.collectSideEffect
+import java.time.ZoneOffset
+
+@Composable
+fun DateFortuneResultRoute(
+ ids: List,
+ snackbarHostState: SnackbarHostState,
+ onNavigateBack: () -> Unit,
+ onAskTodakClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: DateFortuneResultViewModel = hiltViewModel(),
+) {
+ val state by viewModel.collectAsState()
+ val context = LocalContext.current
+ val coroutineScope = rememberCoroutineScope()
+ val loadErrorMessage = stringResource(R.string.date_fortune_result_load_error)
+ val shareErrorMessage = stringResource(R.string.fortune_share_kakao_error)
+ val urlCopiedMessage = stringResource(R.string.fortune_share_url_copied)
+ val calendarErrorMessage = stringResource(R.string.date_fortune_export_calendar_error)
+
+ LaunchedEffect(ids) {
+ viewModel.loadResults(ids)
+ }
+
+ viewModel.collectSideEffect { effect ->
+ when (effect) {
+ DateFortuneResultSideEffect.ShowError -> {
+ snackbarHostState.showSnackbar(loadErrorMessage)
+ }
+
+ DateFortuneResultSideEffect.ShowShareError -> {
+ snackbarHostState.showSnackbar(shareErrorMessage)
+ }
+
+ DateFortuneResultSideEffect.ShowUrlCopied -> {
+ snackbarHostState.showSnackbar(urlCopiedMessage)
+ }
+ }
+ }
+
+ DateFortuneResultScreen(
+ state = state,
+ onBackClick = onNavigateBack,
+ onTabSelect = viewModel::selectTabResult,
+ onShareClick = viewModel::showShareDialog,
+ onShareDismiss = viewModel::hideShareDialog,
+ onKakaoShareFail = viewModel::notifyShareUnavailable,
+ onUrlCopy = viewModel::notifyUrlCopied,
+ onExportClick = {
+ val fortune = state.results.getOrNull(state.selectedResultIndex)
+ if (fortune != null) {
+ try {
+ context.startActivity(fortune.toCalendarInsertIntent())
+ } catch (e: ActivityNotFoundException) {
+ coroutineScope.launch { snackbarHostState.showSnackbar(calendarErrorMessage) }
+ }
+ }
+ },
+ onAskTodakClick = onAskTodakClick,
+ modifier = modifier,
+ )
+}
+
+private fun DayFortune.toCalendarInsertIntent(): Intent {
+ val beginMillis = targetDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
+ val endMillis =
+ targetDate
+ .plusDays(1)
+ .atStartOfDay(ZoneOffset.UTC)
+ .toInstant()
+ .toEpochMilli()
+ return Intent(Intent.ACTION_INSERT, CalendarContract.Events.CONTENT_URI).apply {
+ putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, beginMillis)
+ putExtra(CalendarContract.EXTRA_EVENT_END_TIME, endMillis)
+ putExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, true)
+ putExtra(CalendarContract.Events.TITLE, title)
+ putExtra(CalendarContract.Events.DESCRIPTION, content)
+ }
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/DateFortuneResultViewModel.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/DateFortuneResultViewModel.kt
new file mode 100644
index 00000000..adea25b6
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/DateFortuneResultViewModel.kt
@@ -0,0 +1,80 @@
+package com.kikidan.sajucontents
+
+import androidx.lifecycle.ViewModel
+import com.kikidan.domain.usecase.GetDayFortuneUseCase
+import com.kikidan.sajucontents.model.DateFortuneResultSideEffect
+import com.kikidan.sajucontents.model.DateFortuneResultState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.collections.immutable.toPersistentList
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import org.orbitmvi.orbit.Container
+import org.orbitmvi.orbit.ContainerHost
+import org.orbitmvi.orbit.viewmodel.container
+import javax.inject.Inject
+
+@HiltViewModel
+class DateFortuneResultViewModel
+ @Inject
+ constructor(
+ private val getDayFortune: GetDayFortuneUseCase,
+ ) : ViewModel(),
+ ContainerHost {
+ override val container: Container =
+ container(DateFortuneResultState())
+
+ fun loadResults(ids: List) =
+ intent {
+ reduce { state.copy(isLoading = true) }
+
+ // 탭 순서(선택한 날짜 순서)를 유지하기 위해 병렬 조회 후 요청한 id 순서 그대로 매핑한다.
+ val results = coroutineScope { ids.map { id -> async { getDayFortune(id) } }.awaitAll() }
+ val failure = results.firstOrNull { it.isFailure }
+ if (failure != null) {
+ reduce { state.copy(isLoading = false) }
+ postSideEffect(DateFortuneResultSideEffect.ShowError)
+ return@intent
+ }
+
+ reduce {
+ state.copy(
+ isLoading = false,
+ results =
+ results
+ .map { it.getOrThrow() }
+ .sortedByDescending { it.score }
+ .take(3)
+ .toPersistentList(),
+ selectedResultIndex = 0,
+ )
+ }
+ }
+
+ fun selectTabResult(index: Int) =
+ intent {
+ reduce { state.copy(selectedResultIndex = index) }
+ }
+
+ fun showShareDialog() =
+ intent {
+ reduce { state.copy(isShareDialogVisible = true) }
+ }
+
+ fun hideShareDialog() =
+ intent {
+ reduce { state.copy(isShareDialogVisible = false) }
+ }
+
+ fun notifyShareUnavailable() =
+ intent {
+ reduce { state.copy(isShareDialogVisible = false) }
+ postSideEffect(DateFortuneResultSideEffect.ShowShareError)
+ }
+
+ fun notifyUrlCopied() =
+ intent {
+ reduce { state.copy(isShareDialogVisible = false) }
+ postSideEffect(DateFortuneResultSideEffect.ShowUrlCopied)
+ }
+ }
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/DateFortuneViewModel.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/DateFortuneViewModel.kt
new file mode 100644
index 00000000..ac8f78b5
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/DateFortuneViewModel.kt
@@ -0,0 +1,98 @@
+package com.kikidan.sajucontents
+
+import androidx.lifecycle.ViewModel
+import com.kikidan.domain.model.dayfortune.DayFortunePurpose
+import com.kikidan.domain.model.user.Gender
+import com.kikidan.domain.usecase.CreateDayFortunesUseCase
+import com.kikidan.domain.usecase.DateFortuneDefaults
+import com.kikidan.sajucontents.model.DateFortuneSideEffect
+import com.kikidan.sajucontents.model.DateFortuneState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toPersistentList
+import org.orbitmvi.orbit.Container
+import org.orbitmvi.orbit.ContainerHost
+import org.orbitmvi.orbit.viewmodel.container
+import java.time.LocalDate
+import javax.inject.Inject
+
+@HiltViewModel
+class DateFortuneViewModel
+ @Inject
+ constructor(
+ private val createDayFortunes: CreateDayFortunesUseCase,
+ ) : ViewModel(),
+ ContainerHost {
+ // orbit-viewmodel의 `ViewModel.container(...)` 팩토리는 Container를 반환하므로
+ // ContainerHost.container 프로퍼티를 override하는 형태로 위임한다.
+ override val container: Container =
+ container(DateFortuneState())
+
+ fun selectPurpose(purpose: DayFortunePurpose) =
+ intent {
+ reduce { state.copy(selectedPurpose = purpose) }
+ }
+
+ fun selectGender(gender: Gender) =
+ intent {
+ reduce { state.copy(selectedGender = gender) }
+ }
+
+ fun openDateSheet() =
+ intent {
+ reduce { state.copy(isSheetVisible = true) }
+ }
+
+ fun closeDateSheet() =
+ intent {
+ reduce { state.copy(isSheetVisible = false) }
+ }
+
+ fun toggleDate(date: LocalDate) =
+ intent {
+ val current = state.selectedDates
+ when {
+ current.contains(date) -> {
+ reduce { state.copy(selectedDates = current.filterNot { it == date }.toPersistentList()) }
+ }
+
+ current.size >= DateFortuneDefaults.MAX_TARGET_DATES -> {
+ postSideEffect(DateFortuneSideEffect.ShowToast)
+ }
+
+ else -> {
+ reduce { state.copy(selectedDates = (current + date).toPersistentList()) }
+ }
+ }
+ }
+
+ fun removeDate(date: LocalDate) =
+ intent {
+ reduce { state.copy(selectedDates = state.selectedDates.filterNot { it == date }.toPersistentList()) }
+ }
+
+ fun reset() =
+ intent {
+ reduce { state.copy(selectedDates = persistentListOf()) }
+ }
+
+ fun submit() =
+ intent {
+ val purpose = state.selectedPurpose ?: return@intent
+ val dates = state.selectedDates
+ if (dates.isEmpty() || state.isLoading) return@intent
+
+ reduce { state.copy(isLoading = true) }
+
+ createDayFortunes(purpose, dates).fold(
+ onSuccess = { results ->
+ reduce { state.copy(isLoading = false) }
+ postSideEffect(DateFortuneSideEffect.NavigateToResult(results.map { it.id }))
+ },
+ onFailure = {
+ reduce { state.copy(isLoading = false) }
+ postSideEffect(DateFortuneSideEffect.ShowError)
+ },
+ )
+ }
+ }
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/YearFortuneResultRoute.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/YearFortuneResultRoute.kt
new file mode 100644
index 00000000..d7c63002
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/YearFortuneResultRoute.kt
@@ -0,0 +1,54 @@
+package com.kikidan.sajucontents
+
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.kikidan.sajucontents.model.YearFortuneResultSideEffect
+import com.kikidan.sajucontents.screen.YearFortuneResultScreen
+import org.orbitmvi.orbit.compose.collectAsState
+import org.orbitmvi.orbit.compose.collectSideEffect
+import com.kikidan.designsystem.R as DesignSystemR
+
+// id를 직접 전달받아 스스로 데이터를 불러온다. 공유 딥링크로 바로 진입할 수 있도록
+// 연도 선택 화면과 별개인 YearFortuneResultViewModel을 사용한다.
+@Composable
+fun YearFortuneResultRoute(
+ id: String,
+ snackbarHostState: SnackbarHostState,
+ onNavigateBack: () -> Unit,
+ onAskTodakClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: YearFortuneResultViewModel = hiltViewModel(),
+) {
+ val state by viewModel.collectAsState()
+ val loadErrorMessage = stringResource(R.string.year_fortune_load_error)
+ val shareErrorMessage = stringResource(DesignSystemR.string.fortune_share_kakao_error)
+ val urlCopiedMessage = stringResource(DesignSystemR.string.fortune_share_url_copied)
+
+ LaunchedEffect(id) {
+ viewModel.load(id)
+ }
+
+ viewModel.collectSideEffect { effect ->
+ when (effect) {
+ YearFortuneResultSideEffect.ShowError -> snackbarHostState.showSnackbar(loadErrorMessage)
+ YearFortuneResultSideEffect.ShowShareError -> snackbarHostState.showSnackbar(shareErrorMessage)
+ YearFortuneResultSideEffect.ShowUrlCopied -> snackbarHostState.showSnackbar(urlCopiedMessage)
+ }
+ }
+
+ YearFortuneResultScreen(
+ state = state,
+ onBackClick = onNavigateBack,
+ onShareClick = viewModel::showShareDialog,
+ onShareDismiss = viewModel::hideShareDialog,
+ onKakaoShareFail = viewModel::notifyShareUnavailable,
+ onUrlCopy = viewModel::notifyUrlCopied,
+ onAskTodakClick = onAskTodakClick,
+ modifier = modifier,
+ )
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/YearFortuneResultViewModel.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/YearFortuneResultViewModel.kt
new file mode 100644
index 00000000..b1f204c6
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/YearFortuneResultViewModel.kt
@@ -0,0 +1,62 @@
+package com.kikidan.sajucontents
+
+import androidx.lifecycle.ViewModel
+import com.kikidan.domain.usecase.GetYearFortuneUseCase
+import com.kikidan.sajucontents.model.YearFortuneResultSideEffect
+import com.kikidan.sajucontents.model.YearFortuneResultState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import org.orbitmvi.orbit.Container
+import org.orbitmvi.orbit.ContainerHost
+import org.orbitmvi.orbit.viewmodel.container
+import javax.inject.Inject
+
+// id를 직접 전달받아 스스로 데이터를 불러온다. 공유 딥링크로 결과 화면에 바로 진입할 수 있도록
+// 연도 선택 화면(YearFortuneViewModel)과 완전히 분리된 인스턴스로 존재한다.
+@HiltViewModel
+class YearFortuneResultViewModel
+ @Inject
+ constructor(
+ private val getYearFortune: GetYearFortuneUseCase,
+ ) : ViewModel(),
+ ContainerHost {
+ override val container: Container =
+ container(YearFortuneResultState())
+
+ fun load(id: String) =
+ intent {
+ if (state.isLoading) return@intent
+ reduce { state.copy(isLoading = true) }
+
+ getYearFortune(id).fold(
+ onSuccess = { result ->
+ reduce { state.copy(isLoading = false, fortuneResult = result) }
+ },
+ onFailure = {
+ reduce { state.copy(isLoading = false) }
+ postSideEffect(YearFortuneResultSideEffect.ShowError)
+ },
+ )
+ }
+
+ fun showShareDialog() =
+ intent {
+ reduce { state.copy(isShareDialogVisible = true) }
+ }
+
+ fun hideShareDialog() =
+ intent {
+ reduce { state.copy(isShareDialogVisible = false) }
+ }
+
+ fun notifyShareUnavailable() =
+ intent {
+ reduce { state.copy(isShareDialogVisible = false) }
+ postSideEffect(YearFortuneResultSideEffect.ShowShareError)
+ }
+
+ fun notifyUrlCopied() =
+ intent {
+ reduce { state.copy(isShareDialogVisible = false) }
+ postSideEffect(YearFortuneResultSideEffect.ShowUrlCopied)
+ }
+ }
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/YearFortuneViewModel.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/YearFortuneViewModel.kt
new file mode 100644
index 00000000..25b6d91f
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/YearFortuneViewModel.kt
@@ -0,0 +1,45 @@
+package com.kikidan.sajucontents
+
+import androidx.lifecycle.ViewModel
+import com.kikidan.domain.usecase.CreateYearFortuneUseCase
+import com.kikidan.sajucontents.model.YearFortuneSideEffect
+import com.kikidan.sajucontents.model.YearFortuneState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import org.orbitmvi.orbit.Container
+import org.orbitmvi.orbit.ContainerHost
+import org.orbitmvi.orbit.viewmodel.container
+import javax.inject.Inject
+
+@HiltViewModel
+class YearFortuneViewModel
+ @Inject
+ constructor(
+ private val createYearFortune: CreateYearFortuneUseCase,
+ ) : ViewModel(),
+ ContainerHost {
+ override val container: Container =
+ container(YearFortuneState())
+
+ fun onYearSelect(year: Int) =
+ intent {
+ reduce { state.copy(selectedYear = year) }
+ }
+
+ fun onSubmit() =
+ intent {
+ if (state.isLoading) return@intent
+ val year = state.selectedYear
+ reduce { state.copy(isLoading = true) }
+
+ createYearFortune(year).fold(
+ onSuccess = { result ->
+ reduce { state.copy(isLoading = false) }
+ postSideEffect(YearFortuneSideEffect.NavigateToResult(result.id))
+ },
+ onFailure = {
+ reduce { state.copy(isLoading = false) }
+ postSideEffect(YearFortuneSideEffect.ShowError)
+ },
+ )
+ }
+ }
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/YearSelectionRoute.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/YearSelectionRoute.kt
new file mode 100644
index 00000000..aa8eb77b
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/YearSelectionRoute.kt
@@ -0,0 +1,39 @@
+package com.kikidan.sajucontents
+
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.kikidan.sajucontents.model.YearFortuneSideEffect
+import com.kikidan.sajucontents.screen.YearFortuneInputScreen
+import org.orbitmvi.orbit.compose.collectAsState
+import org.orbitmvi.orbit.compose.collectSideEffect
+
+@Composable
+fun YearSelectionRoute(
+ snackbarHostState: SnackbarHostState,
+ onNavigateBack: () -> Unit,
+ onNavigateToResult: (id: String) -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: YearFortuneViewModel = hiltViewModel(),
+) {
+ val state by viewModel.collectAsState()
+ val loadErrorMessage = stringResource(R.string.year_fortune_load_error)
+
+ viewModel.collectSideEffect { effect ->
+ when (effect) {
+ is YearFortuneSideEffect.NavigateToResult -> onNavigateToResult(effect.id)
+ YearFortuneSideEffect.ShowError -> snackbarHostState.showSnackbar(loadErrorMessage)
+ }
+ }
+
+ YearFortuneInputScreen(
+ state = state,
+ onBackClick = onNavigateBack,
+ onYearSelect = viewModel::onYearSelect,
+ onSubmit = viewModel::onSubmit,
+ modifier = modifier,
+ )
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/CompatibilityOhaengBars.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/CompatibilityOhaengBars.kt
new file mode 100644
index 00000000..2429dc6c
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/CompatibilityOhaengBars.kt
@@ -0,0 +1,110 @@
+package com.kikidan.sajucontents.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.kikidan.designsystem.theme.TodakunColor
+import com.kikidan.designsystem.theme.TodakunTheme
+import com.kikidan.designsystem.theme.TodakunTypography
+import com.kikidan.domain.model.compatibility.CompatibilityOhaeng
+import com.kikidan.domain.model.saju.Ohaeng
+
+private val OhaengBarGradient = Brush.horizontalGradient(listOf(TodakunColor.sky300, TodakunColor.primary300))
+
+@Composable
+internal fun CompatibilityOhaengBars(
+ ohaengs: List,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ ohaengs.forEach { entry -> OhaengBarRow(entry = entry) }
+ }
+}
+
+@Composable
+private fun OhaengBarRow(
+ entry: CompatibilityOhaeng,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier) {
+ Row {
+ Text(
+ text = "${entry.ohaeng.displayName} ",
+ style = TodakunTypography.body3Medium,
+ color = TodakunColor.white,
+ )
+ Text(
+ text = entry.ohaeng.hanja,
+ style = TodakunTypography.body3Medium,
+ color = TodakunColor.whiteOpacity60,
+ )
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .weight(1f)
+ .height(8.dp)
+ .clip(RoundedCornerShape(99.dp))
+ .background(TodakunColor.whiteOpacity20),
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth((entry.percentage / 100f).coerceIn(0f, 1f))
+ .fillMaxHeight()
+ .clip(RoundedCornerShape(99.dp))
+ .background(OhaengBarGradient),
+ )
+ }
+ Text(
+ text = "${entry.percentage}%",
+ style = TodakunTypography.body3Medium,
+ textAlign = TextAlign.End,
+ color = TodakunColor.primary200,
+ modifier = Modifier.width(40.dp),
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFF00010B)
+@Composable
+private fun CompatibilityOhaengBarsPreview() {
+ TodakunTheme {
+ CompatibilityOhaengBars(
+ ohaengs =
+ listOf(
+ CompatibilityOhaeng(Ohaeng.MOK, 25),
+ CompatibilityOhaeng(Ohaeng.HWA, 30),
+ CompatibilityOhaeng(Ohaeng.TO, 15),
+ CompatibilityOhaeng(Ohaeng.GEUM, 15),
+ CompatibilityOhaeng(Ohaeng.SU, 15),
+ ),
+ )
+ }
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/CompatibilitySajuPillars.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/CompatibilitySajuPillars.kt
new file mode 100644
index 00000000..bd124e57
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/CompatibilitySajuPillars.kt
@@ -0,0 +1,197 @@
+package com.kikidan.sajucontents.component
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.kikidan.designsystem.R
+import com.kikidan.designsystem.theme.TodakunColor
+import com.kikidan.designsystem.theme.TodakunTheme
+import com.kikidan.designsystem.theme.TodakunTypography
+import com.kikidan.domain.model.saju.CheonGan
+import com.kikidan.domain.model.saju.JiJi
+import com.kikidan.domain.model.saju.Ohaeng
+import com.kikidan.domain.model.saju.SajuPillarDetail
+import com.kikidan.domain.model.saju.SajuPillarType
+import com.kikidan.domain.model.saju.TenGod
+import com.kikidan.domain.model.saju.TwelveUnseong
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Composable
+internal fun CompatibilitySajuPillars(
+ pillars: ImmutableList,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ pillars.forEach { pillar -> PillarColumn(pillar = pillar) }
+ }
+}
+
+@Composable
+private fun PillarColumn(
+ pillar: SajuPillarDetail,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier.width(54.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(pillar.pillarType.labelRes()),
+ style = TodakunTypography.caption3Regular,
+ color = TodakunColor.gray700,
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ Text(
+ text = stringResource(pillar.stemTenGod.labelRes()),
+ style = TodakunTypography.caption1Regular,
+ color = TodakunColor.gray975,
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+
+ SajuCard(
+ hanja = pillar.cheonGan.hanja,
+ reading = pillar.cheonGan.displayName,
+ ohaeng = pillar.cheonGan.ohaeng,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+
+ SajuCard(
+ hanja = pillar.jiJi.hanja,
+ reading = pillar.jiJi.displayName,
+ ohaeng = pillar.jiJi.ohaeng,
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+
+ Text(
+ text = stringResource(pillar.branchTenGod.labelRes()),
+ style = TodakunTypography.caption1Regular,
+ color = TodakunColor.gray975,
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+
+ Text(
+ text = stringResource(pillar.twelveUnseong.labelRes()),
+ style = TodakunTypography.caption1Regular,
+ color = TodakunColor.gray975,
+ )
+ }
+}
+
+@StringRes
+private fun SajuPillarType.labelRes(): Int =
+ when (this) {
+ SajuPillarType.HOUR -> R.string.mansaeryeok_pillar_hour
+ SajuPillarType.DAY -> R.string.mansaeryeok_pillar_day
+ SajuPillarType.MONTH -> R.string.mansaeryeok_pillar_month
+ SajuPillarType.YEAR -> R.string.mansaeryeok_pillar_year
+ }
+
+@StringRes
+private fun TwelveUnseong.labelRes(): Int =
+ when (this) {
+ TwelveUnseong.JANGSAENG -> R.string.mansaeryeok_twelve_unseong_jangsaeng
+ TwelveUnseong.MOKYOK -> R.string.mansaeryeok_twelve_unseong_mogyok
+ TwelveUnseong.GWANDAE -> R.string.mansaeryeok_twelve_unseong_gwandae
+ TwelveUnseong.GEONROK -> R.string.mansaeryeok_twelve_unseong_geonrok
+ TwelveUnseong.JEWANG -> R.string.mansaeryeok_twelve_unseong_jewang
+ TwelveUnseong.SOE -> R.string.mansaeryeok_twelve_unseong_soe
+ TwelveUnseong.BYEONG -> R.string.mansaeryeok_twelve_unseong_byeong
+ TwelveUnseong.SA -> R.string.mansaeryeok_twelve_unseong_sa
+ TwelveUnseong.MYO -> R.string.mansaeryeok_twelve_unseong_myo
+ TwelveUnseong.JEOL -> R.string.mansaeryeok_twelve_unseong_jeol
+ TwelveUnseong.TAE -> R.string.mansaeryeok_twelve_unseong_tae
+ TwelveUnseong.YANG -> R.string.mansaeryeok_twelve_unseong_yang
+ }
+
+@StringRes
+private fun TenGod.labelRes(): Int =
+ when (this) {
+ TenGod.ILWON -> R.string.mansaeryeok_ten_god_ilwon
+ TenGod.BIGYEON -> R.string.mansaeryeok_ten_god_bigyeon
+ TenGod.GEOPJAE -> R.string.mansaeryeok_ten_god_gyeopjae
+ TenGod.SIKSIN -> R.string.mansaeryeok_ten_god_siksin
+ TenGod.SANGGWAN -> R.string.mansaeryeok_ten_god_sanggwan
+ TenGod.PYEONJAE -> R.string.mansaeryeok_ten_god_pyeonjae
+ TenGod.JEONGJAE -> R.string.mansaeryeok_ten_god_jeongjae
+ TenGod.PYEONGWAN -> R.string.mansaeryeok_ten_god_pyeongwan
+ TenGod.JEONGGWAN -> R.string.mansaeryeok_ten_god_jeonggwan
+ TenGod.PYEONIN -> R.string.mansaeryeok_ten_god_pyeonin
+ TenGod.JEONGIN -> R.string.mansaeryeok_ten_god_jeongin
+ }
+
+@Preview(showBackground = true)
+@Composable
+private fun CompatibilitySajuPillarsPreview() {
+ TodakunTheme {
+ CompatibilitySajuPillars(
+ pillars =
+ persistentListOf(
+ SajuPillarDetail(
+ SajuPillarType.HOUR,
+ CheonGan.SIN,
+ JiJi.MI,
+ TenGod.SIKSIN,
+ TenGod.BIGYEON,
+ emptyList(),
+ TwelveUnseong.GWANDAE,
+ com.kikidan.domain.model.saju.TwelveSinsal.entries
+ .first(),
+ ),
+ SajuPillarDetail(
+ SajuPillarType.DAY,
+ CheonGan.GI,
+ JiJi.SA,
+ TenGod.ILWON,
+ TenGod.BIGYEON,
+ emptyList(),
+ TwelveUnseong.GWANDAE,
+ com.kikidan.domain.model.saju.TwelveSinsal.entries
+ .first(),
+ ),
+ SajuPillarDetail(
+ SajuPillarType.MONTH,
+ CheonGan.GYE,
+ JiJi.MYO,
+ TenGod.PYEONJAE,
+ TenGod.BIGYEON,
+ emptyList(),
+ TwelveUnseong.GWANDAE,
+ com.kikidan.domain.model.saju.TwelveSinsal.entries
+ .first(),
+ ),
+ SajuPillarDetail(
+ SajuPillarType.YEAR,
+ CheonGan.JEONG,
+ JiJi.CHUK,
+ TenGod.PYEONIN,
+ TenGod.BIGYEON,
+ emptyList(),
+ TwelveUnseong.GWANDAE,
+ com.kikidan.domain.model.saju.TwelveSinsal.entries
+ .first(),
+ ),
+ ),
+ )
+ }
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/CompatibilityScoreCard.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/CompatibilityScoreCard.kt
new file mode 100644
index 00000000..c9ce3d05
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/CompatibilityScoreCard.kt
@@ -0,0 +1,136 @@
+package com.kikidan.sajucontents.component
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.kikidan.designsystem.theme.TodakunColor
+import com.kikidan.designsystem.theme.TodakunTheme
+import com.kikidan.designsystem.theme.TodakunTypography
+import com.kikidan.sajucontents.R
+import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.hazeEffect
+
+private object CompatibilityScoreCardDefaults {
+ val GaugeSize = 180.dp
+ val GaugeStrokeWidth = 13.dp
+}
+
+private const val GAUGE_MAX_SCORE = 100
+
+@Composable
+internal fun CompatibilityScoreCard(
+ score: Int,
+ subheadline: String,
+ summary: String,
+ hazeState: HazeState,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .hazeEffect(state = hazeState) { blurRadius = 20.dp }
+ .background(TodakunColor.whiteOpacity10)
+ .padding(20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(20.dp),
+ ) {
+ ScoreGauge(score = score)
+ Text(
+ modifier = Modifier.align(Alignment.Start),
+ text = subheadline,
+ style = TodakunTypography.heading4Bold,
+ color = TodakunColor.white,
+ textAlign = TextAlign.Start,
+ )
+ Text(
+ modifier = Modifier.align(Alignment.Start),
+ text = summary,
+ style = TodakunTypography.body3Regular,
+ color = TodakunColor.whiteOpacity60,
+ textAlign = TextAlign.Start,
+ )
+ }
+}
+
+@Composable
+private fun ScoreGauge(
+ score: Int,
+ modifier: Modifier = Modifier,
+) {
+ Box(modifier = modifier.size(CompatibilityScoreCardDefaults.GaugeSize), contentAlignment = Alignment.Center) {
+ Canvas(modifier = Modifier.fillMaxSize()) {
+ val strokeWidthPx = CompatibilityScoreCardDefaults.GaugeStrokeWidth.toPx()
+ val arcSize = Size(size.width - strokeWidthPx, size.height - strokeWidthPx)
+ val topLeft = Offset(strokeWidthPx / 2f, strokeWidthPx / 2f)
+ val stroke = Stroke(width = strokeWidthPx, cap = StrokeCap.Round)
+
+ drawArc(
+ color = TodakunColor.blackOpacity10,
+ startAngle = -90f,
+ sweepAngle = 360f,
+ useCenter = false,
+ topLeft = topLeft,
+ size = arcSize,
+ style = stroke,
+ )
+ drawArc(
+ color = TodakunColor.primary400,
+ startAngle = -90f,
+ sweepAngle = 360f * score.coerceIn(0, GAUGE_MAX_SCORE) / GAUGE_MAX_SCORE,
+ useCenter = false,
+ topLeft = topLeft,
+ size = arcSize,
+ style = stroke,
+ )
+ }
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = score.toString(),
+ style = TodakunTypography.heading1Bold.copy(fontSize = 40.sp, lineHeight = 52.sp),
+ color = TodakunColor.white,
+ )
+ Text(
+ text = stringResource(id = R.string.compatibility_score_label),
+ style = TodakunTypography.body3Regular,
+ color = TodakunColor.whiteOpacity60,
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFF00010B)
+@Composable
+private fun CompatibilityScoreCardPreview() {
+ TodakunTheme {
+ CompatibilityScoreCard(
+ score = 85,
+ subheadline = "함께 있을 때, 편안함이 커지는 사이예요.",
+ summary = "두 분은 서로의 부족한 기운을 보완하며 평온한 안식처가 되어주는 최상의 흐름을 가지고 있습니다.",
+ hazeState = HazeState(),
+ )
+ }
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/DateSelectBottomSheet.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/DateSelectBottomSheet.kt
new file mode 100644
index 00000000..01811ce9
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/DateSelectBottomSheet.kt
@@ -0,0 +1,153 @@
+package com.kikidan.sajucontents.component
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.BottomSheetDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+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.unit.dp
+import com.kikidan.designsystem.R
+import com.kikidan.designsystem.component.button.PrimaryButton
+import com.kikidan.designsystem.component.button.SecondaryButton
+import com.kikidan.designsystem.component.button.TodakunButtonSize
+import com.kikidan.designsystem.theme.TodakunColor
+import com.kikidan.designsystem.theme.TodakunTypography
+import com.kikidan.designsystem.util.noRippleClickable
+import com.kikidan.domain.usecase.DateFortuneDefaults
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.coroutines.launch
+import java.time.LocalDate
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun DateSelectBottomSheet(
+ selectedDates: ImmutableList,
+ onDateToggle: (LocalDate) -> Unit,
+ onDateRemove: (LocalDate) -> Unit,
+ onReset: () -> Unit,
+ onConfirm: () -> Unit,
+ onDismissRequest: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val scope = rememberCoroutineScope()
+ ModalBottomSheet(
+ onDismissRequest = onDismissRequest,
+ sheetState = sheetState,
+ containerColor = TodakunColor.white,
+ dragHandle = { BottomSheetDefaults.DragHandle(color = TodakunColor.gray200) },
+ modifier = modifier,
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(0.8f)
+ .navigationBarsPadding()
+ .padding(horizontal = 20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ DateSelectBottomSheetTitle(
+ onDismissClick = {
+ scope.launch {
+ sheetState.hide()
+ onDismissRequest()
+ }
+ },
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ MultiSelectCalendar(
+ selectedDates = selectedDates,
+ onDateToggle = onDateToggle,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ )
+ SelectedDateChipRow(
+ selectedDates = selectedDates,
+ onDateRemove = onDateRemove,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp),
+ )
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ SecondaryButton(
+ text = stringResource(id = R.string.date_fortune_reset),
+ onClick = onReset,
+ size = TodakunButtonSize.Large,
+ )
+ PrimaryButton(
+ text = stringResource(id = R.string.date_fortune_confirm),
+ onClick = onConfirm,
+ enabled = selectedDates.isNotEmpty(),
+ size = TodakunButtonSize.Large,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun DateSelectBottomSheetTitle(
+ onDismissClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column {
+ Text(
+ text = stringResource(id = R.string.date_fortune_sheet_title),
+ style = TodakunTypography.heading3Bold,
+ color = TodakunColor.gray975,
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text =
+ stringResource(
+ id = R.string.date_fortune_sheet_subtitle,
+ DateFortuneDefaults.MAX_TARGET_DATES,
+ ),
+ style = TodakunTypography.body3Regular,
+ color = TodakunColor.gray700,
+ )
+ }
+
+ Icon(
+ modifier = Modifier.size(24.dp).noRippleClickable(onClick = onDismissClick),
+ tint = TodakunColor.gray925,
+ painter = painterResource(com.kikidan.designsystem.R.drawable.ic_close),
+ contentDescription = stringResource(R.string.date_fortune_close_content_description),
+ )
+ }
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/FortuneScoreCard.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/FortuneScoreCard.kt
new file mode 100644
index 00000000..2dd3aec4
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/FortuneScoreCard.kt
@@ -0,0 +1,194 @@
+package com.kikidan.sajucontents.component
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.blur
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.kikidan.designsystem.R
+import com.kikidan.designsystem.theme.TodakunColor
+import com.kikidan.designsystem.theme.TodakunTypography
+import com.kikidan.domain.model.fortune.FortuneCategory
+import com.kikidan.domain.model.fortune.FortuneCategoryStar
+import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.hazeEffect
+import kotlinx.collections.immutable.ImmutableList
+
+private object FortuneScoreCardDefaults {
+ val GaugeSize = 180.dp
+ val GaugeStrokeWidth = 13.dp
+}
+
+private const val GAUGE_MAX_SCORE = 100
+private const val MAX_STAR = 3
+
+@Composable
+internal fun FortuneScoreCard(
+ score: Int,
+ title: String,
+ categoryStars: ImmutableList,
+ hazeState: HazeState,
+ modifier: Modifier = Modifier,
+ scoreLabel: String = stringResource(id = R.string.fortune_score_label),
+) {
+ Column(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .hazeEffect(state = hazeState) { blurRadius = 20.dp }
+ .background(TodakunColor.white.copy(alpha = 0.1f))
+ .padding(horizontal = 20.dp, vertical = 23.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ ScoreGauge(score = score, scoreLabel = scoreLabel)
+ Text(
+ text = title,
+ style = TodakunTypography.heading4Bold,
+ color = TodakunColor.white,
+ textAlign = TextAlign.Start,
+ modifier = Modifier.align(Alignment.Start),
+ )
+ FortuneCategoryStars(categoryStars = categoryStars)
+ }
+}
+
+@Composable
+private fun ScoreGauge(
+ score: Int,
+ scoreLabel: String,
+ modifier: Modifier = Modifier,
+) {
+ Box(modifier = modifier.size(FortuneScoreCardDefaults.GaugeSize), contentAlignment = Alignment.Center) {
+ Canvas(modifier = Modifier.fillMaxSize()) {
+ val strokeWidthPx = FortuneScoreCardDefaults.GaugeStrokeWidth.toPx()
+ val arcSize = Size(size.width - strokeWidthPx, size.height - strokeWidthPx)
+ val topLeft = Offset(strokeWidthPx / 2f, strokeWidthPx / 2f)
+ val stroke = Stroke(width = strokeWidthPx, cap = StrokeCap.Round)
+
+ drawArc(
+ color = TodakunColor.blackOpacity10,
+ startAngle = -90f,
+ sweepAngle = 360f,
+ useCenter = false,
+ topLeft = topLeft,
+ size = arcSize,
+ style = stroke,
+ )
+ drawArc(
+ color = TodakunColor.primary400,
+ startAngle = -90f,
+ sweepAngle = 360f * score.coerceIn(0, GAUGE_MAX_SCORE) / GAUGE_MAX_SCORE,
+ useCenter = false,
+ topLeft = topLeft,
+ size = arcSize,
+ style = stroke,
+ )
+ }
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = score.toString(),
+ style =
+ TodakunTypography.heading1Bold.copy(
+ fontSize = 40.sp,
+ lineHeight = 52.sp,
+ ),
+ color = TodakunColor.white,
+ )
+ Text(
+ text = scoreLabel,
+ style = TodakunTypography.body3Regular,
+ color = TodakunColor.whiteOpacity60,
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun FortuneCategoryStars(
+ categoryStars: ImmutableList,
+ modifier: Modifier = Modifier,
+) {
+ FlowRow(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ categoryStars.forEach { categoryStar ->
+ CategoryStarItem(
+ categoryStar = categoryStar,
+ )
+ }
+ }
+}
+
+@Composable
+private fun CategoryStarItem(
+ categoryStar: FortuneCategoryStar,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = categoryStar.category.label(),
+ style = TodakunTypography.caption1Regular,
+ color = TodakunColor.white.copy(alpha = 0.6f),
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
+ repeat(MAX_STAR) { index ->
+ Icon(
+ painter =
+ painterResource(
+ id = if (index < categoryStar.star) R.drawable.ic_star_filled else R.drawable.ic_star_empty,
+ ),
+ contentDescription = null,
+ tint = Color.Unspecified,
+ modifier = Modifier.size(width = 14.dp, height = 13.dp),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun FortuneCategory.label(): String =
+ stringResource(
+ id =
+ when (this) {
+ FortuneCategory.RELATIONSHIP -> R.string.fortune_category_relationship
+ FortuneCategory.LOVE -> R.string.fortune_category_love
+ FortuneCategory.ACHIEVEMENT -> R.string.fortune_category_achievement
+ FortuneCategory.MONEY -> R.string.fortune_category_money
+ FortuneCategory.HEALTH -> R.string.fortune_category_health
+ },
+ )
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/FortuneShareDialog.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/FortuneShareDialog.kt
new file mode 100644
index 00000000..644ec5aa
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/FortuneShareDialog.kt
@@ -0,0 +1,218 @@
+package com.kikidan.sajucontents.component
+
+import android.content.ClipData
+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.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.material3.ripple
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.ClipEntry
+import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import com.kakao.sdk.common.util.KakaoCustomTabsClient
+import com.kakao.sdk.share.ShareClient
+import com.kakao.sdk.share.WebSharerClient
+import com.kakao.sdk.template.model.Link
+import com.kakao.sdk.template.model.TextTemplate
+import com.kikidan.designsystem.R
+import com.kikidan.designsystem.component.button.SecondaryButton
+import com.kikidan.designsystem.component.button.TodakunButtonSize
+import com.kikidan.designsystem.theme.TodakunColor
+import com.kikidan.designsystem.theme.TodakunTheme
+import com.kikidan.designsystem.theme.TodakunTypography
+import kotlinx.coroutines.launch
+
+// Day/Year 등 결과 화면 공용 공유 다이얼로그. shareUrl은 화면(도메인)별 경로가 달라(예: day-fortune, year-fortune)
+// 호출부가 직접 만들어 전달한다.
+@Composable
+fun FortuneShareDialog(
+ fortuneTitle: String,
+ fortuneId: String,
+ shareUrl: String,
+ onKakaoShareFail: () -> Unit,
+ onUrlCopy: () -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Dialog(onDismissRequest = onDismiss) {
+ FortuneShareDialogContent(
+ fortuneTitle = fortuneTitle,
+ fortuneId = fortuneId,
+ shareUrl = shareUrl,
+ onKakaoShareFail = onKakaoShareFail,
+ onUrlCopy = onUrlCopy,
+ onDismiss = onDismiss,
+ modifier = modifier,
+ )
+ }
+}
+
+@Composable
+private fun FortuneShareDialogContent(
+ fortuneTitle: String,
+ fortuneId: String,
+ shareUrl: String,
+ onKakaoShareFail: () -> Unit,
+ onUrlCopy: () -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ val clipboard = LocalClipboard.current
+ val coroutineScope = rememberCoroutineScope()
+ val shareMessage = stringResource(id = R.string.fortune_share_message, fortuneTitle)
+ val kakaoLabel = stringResource(id = R.string.fortune_share_kakao)
+ val copyUrlLabel = stringResource(id = R.string.fortune_share_copy_url)
+ val cancelLabel = stringResource(id = R.string.fortune_share_cancel)
+
+ Column(
+ modifier =
+ modifier
+ .width(280.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(TodakunColor.white)
+ .padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = stringResource(id = R.string.fortune_share_dialog_title),
+ style = TodakunTypography.body2SemiBold,
+ color = TodakunColor.gray975,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ ShareActionButton(
+ text = kakaoLabel,
+ containerColor = Color(0xFFFAE301),
+ contentColor = TodakunColor.gray925,
+ iconRes = R.drawable.ic_login_kakao,
+ onClick = {
+ val template =
+ TextTemplate(
+ text = shareMessage,
+ link =
+ Link(
+ webUrl = shareUrl,
+ mobileWebUrl = shareUrl,
+ androidExecutionParams = mapOf("id" to fortuneId),
+ iosExecutionParams = mapOf("id" to fortuneId),
+ ),
+ )
+ if (ShareClient.instance.isKakaoTalkSharingAvailable(context)) {
+ ShareClient.instance.shareDefault(context, template) { result, error ->
+ if (error != null || result == null) {
+ onKakaoShareFail()
+ } else {
+ context.startActivity(result.intent)
+ }
+ }
+ } else {
+ val webUrl = WebSharerClient.instance.makeDefaultUrl(template)
+ KakaoCustomTabsClient.open(context, webUrl)
+ }
+ },
+ )
+ ShareActionButton(
+ text = copyUrlLabel,
+ containerColor = TodakunColor.coolGray700,
+ contentColor = TodakunColor.white,
+ iconRes = null,
+ onClick = {
+ coroutineScope.launch {
+ clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(shareUrl, shareUrl)))
+ onUrlCopy()
+ }
+ },
+ )
+ SecondaryButton(
+ text = cancelLabel,
+ onClick = onDismiss,
+ size = TodakunButtonSize.Medium,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+}
+
+@Composable
+private fun ShareActionButton(
+ text: String,
+ containerColor: Color,
+ contentColor: Color,
+ iconRes: Int?,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .height(44.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(containerColor)
+ .clickable(
+ onClick = onClick,
+ indication = ripple(),
+ interactionSource = remember { MutableInteractionSource() },
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (iconRes != null) {
+ Image(
+ painter = painterResource(id = iconRes),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ Text(
+ text = text,
+ style = TodakunTypography.body3SemiBold,
+ color = contentColor,
+ )
+ }
+ }
+}
+
+@Composable
+@Preview(showBackground = true)
+private fun FortuneShareDialogPreview() {
+ TodakunTheme {
+ FortuneShareDialogContent(
+ fortuneTitle = "이 날짜엔 새로운 시작이 아주 잘 맞아요.",
+ fortuneId = "preview-id",
+ shareUrl = "https://todakun.com/day-fortune?id=preview-id",
+ onKakaoShareFail = {},
+ onUrlCopy = {},
+ onDismiss = {},
+ )
+ }
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/MultiSelectCalendar.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/MultiSelectCalendar.kt
new file mode 100644
index 00000000..3672eec6
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/MultiSelectCalendar.kt
@@ -0,0 +1,182 @@
+package com.kikidan.sajucontents.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+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.draw.clip
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.kikidan.designsystem.R
+import com.kikidan.designsystem.theme.TodakunColor
+import com.kikidan.designsystem.theme.TodakunTypography
+import com.kizitonwose.calendar.compose.VerticalCalendar
+import com.kizitonwose.calendar.compose.rememberCalendarState
+import com.kizitonwose.calendar.core.CalendarDay
+import com.kizitonwose.calendar.core.DayPosition
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.PersistentList
+import java.time.DayOfWeek
+import java.time.LocalDate
+import java.time.YearMonth
+
+/** 캘린더 날짜 셀의 시각 상태 4종. 우선순위: 과거 > 선택 > 오늘 > 기본. */
+internal enum class CalendarDayState {
+ PAST,
+ SELECTED,
+ TODAY,
+ DEFAULT,
+}
+
+/**
+ * 날짜 하나의 캘린더 셀 상태를 판정하는 순수 함수.
+ * Compose에 의존하지 않아 유닛 테스트로 직접 검증한다.
+ */
+internal fun resolveCalendarDayState(
+ date: LocalDate,
+ today: LocalDate,
+ selectedDates: ImmutableList,
+): CalendarDayState =
+ when {
+ date.isBefore(today) -> CalendarDayState.PAST
+ selectedDates.contains(date) -> CalendarDayState.SELECTED
+ date.isEqual(today) -> CalendarDayState.TODAY
+ else -> CalendarDayState.DEFAULT
+ }
+
+private const val MONTH_COUNT = 12L
+
+@Composable
+internal fun MultiSelectCalendar(
+ selectedDates: ImmutableList,
+ onDateToggle: (LocalDate) -> Unit,
+ modifier: Modifier = Modifier,
+ today: LocalDate = LocalDate.now(),
+) {
+ val startMonth = remember(today) { YearMonth.from(today) }
+ val endMonth = remember(startMonth) { startMonth.plusMonths(MONTH_COUNT - 1) }
+ val calendarState =
+ rememberCalendarState(
+ startMonth = startMonth,
+ endMonth = endMonth,
+ firstVisibleMonth = startMonth,
+ firstDayOfWeek = DayOfWeek.SUNDAY,
+ )
+
+ VerticalCalendar(
+ state = calendarState,
+ modifier = modifier,
+ monthHeader = { month ->
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = "${month.yearMonth.year}.${month.yearMonth.monthValue}",
+ style = TodakunTypography.body2SemiBold,
+ color = TodakunColor.gray975,
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ WeekDaysHeader()
+ }
+ },
+ dayContent = { day ->
+ if (day.position == DayPosition.MonthDate) {
+ DayCell(
+ day = day,
+ state = resolveCalendarDayState(day.date, today, selectedDates),
+ onClick = onDateToggle,
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+ },
+ )
+}
+
+@Composable
+private fun WeekDaysHeader(modifier: Modifier = Modifier) {
+ val labels =
+ listOf(
+ R.string.date_fortune_weekday_sun,
+ R.string.date_fortune_weekday_mon,
+ R.string.date_fortune_weekday_tue,
+ R.string.date_fortune_weekday_wed,
+ R.string.date_fortune_weekday_thu,
+ R.string.date_fortune_weekday_fri,
+ R.string.date_fortune_weekday_sat,
+ )
+ Row(modifier = modifier.fillMaxWidth()) {
+ labels.forEach { labelRes ->
+ Text(
+ text = stringResource(labelRes),
+ style = TodakunTypography.caption1Medium,
+ color = TodakunColor.gray700,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.weight(1f).height(44.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun DayCell(
+ day: CalendarDay,
+ state: CalendarDayState,
+ onClick: (LocalDate) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val enabled = state != CalendarDayState.PAST
+ val textColor =
+ when (state) {
+ CalendarDayState.PAST -> TodakunColor.gray300
+ CalendarDayState.SELECTED -> TodakunColor.white
+ CalendarDayState.TODAY -> TodakunColor.primary600
+ CalendarDayState.DEFAULT -> TodakunColor.gray975
+ }
+
+ Box(
+ modifier =
+ modifier
+ .size(44.dp)
+ .clip(CircleShape)
+ .background(if (state == CalendarDayState.SELECTED) TodakunColor.primary400 else TodakunColor.white)
+ .then(
+ if (enabled) {
+ Modifier.clickable { onClick(day.date) }
+ } else {
+ Modifier
+ },
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = day.date.dayOfMonth.toString(),
+ style = TodakunTypography.body2Medium,
+ color = textColor,
+ textAlign = TextAlign.Center,
+ )
+ if (state == CalendarDayState.TODAY) {
+ Text(
+ modifier = Modifier.align(Alignment.BottomCenter),
+ text = stringResource(R.string.date_fortune_today_label),
+ style = TodakunTypography.caption3Medium,
+ color = TodakunColor.primary600,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/ResultDateTabRow.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/ResultDateTabRow.kt
new file mode 100644
index 00000000..e0cd504c
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/ResultDateTabRow.kt
@@ -0,0 +1,69 @@
+package com.kikidan.sajucontents.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.draw.clip
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.unit.dp
+import com.kikidan.designsystem.theme.TodakunColor
+import com.kikidan.designsystem.theme.TodakunTypography
+import com.kikidan.domain.model.dayfortune.DayFortune
+import kotlinx.collections.immutable.ImmutableList
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+
+private val TabDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("M.d(E)", Locale.KOREAN)
+
+/** 결과 화면 상단 날짜 탭. 개수는 [results]에 따라 동적(1~5개, D7 상한 상수 참조). */
+@Composable
+internal fun ResultDateTabRow(
+ results: ImmutableList,
+ selectedIndex: Int,
+ onTabSelect: (Int) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier.fillMaxWidth().height(54.dp).selectableGroup(),
+ horizontalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ results.forEachIndexed { index, fortune ->
+ val selected = index == selectedIndex
+ val label = remember(fortune.targetDate) { fortune.targetDate.format(TabDateFormatter) }
+
+ Box(
+ modifier =
+ Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .clip(RoundedCornerShape(12.dp))
+ .background(if (selected) TodakunColor.primary600 else TodakunColor.white.copy(alpha = 0.05f))
+ .border(
+ width = 1.dp,
+ color = if (selected) TodakunColor.primary500 else TodakunColor.white.copy(alpha = 0.3f),
+ shape = RoundedCornerShape(12.dp),
+ ).selectable(selected = selected, role = Role.Tab, onClick = { onTabSelect(index) }),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = label,
+ style = TodakunTypography.body2Medium,
+ color = TodakunColor.white,
+ )
+ }
+ }
+ }
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/SajuCard.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/SajuCard.kt
new file mode 100644
index 00000000..ac7dd817
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/SajuCard.kt
@@ -0,0 +1,59 @@
+package com.kikidan.sajucontents.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.kikidan.designsystem.R
+import com.kikidan.designsystem.theme.TodakunColor
+import com.kikidan.designsystem.theme.TodakunTypography
+import com.kikidan.domain.model.saju.Ohaeng
+
+@Composable
+internal fun SajuCard(
+ hanja: String,
+ reading: String,
+ ohaeng: Ohaeng,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier =
+ modifier
+ .aspectRatio(1f)
+ .clip(RoundedCornerShape(12.dp))
+ .background(ohaeng.containerColor())
+ .padding(horizontal = 6.dp, vertical = 6.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ text = hanja,
+ style = TodakunTypography.body1Bold,
+ color = TodakunColor.gray975,
+ )
+ Text(
+ text = stringResource(R.string.saju_card_label_format, reading, ohaeng.hanja),
+ style = TodakunTypography.caption3Regular,
+ color = TodakunColor.gray975,
+ )
+ }
+}
+
+private fun Ohaeng.containerColor(): Color =
+ when (this) {
+ Ohaeng.MOK -> TodakunColor.teal200
+ Ohaeng.HWA -> TodakunColor.red200
+ Ohaeng.TO -> TodakunColor.orange200
+ Ohaeng.GEUM -> TodakunColor.coolGray300
+ Ohaeng.SU -> TodakunColor.sky200
+ }
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/SelectedDateChipRow.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/SelectedDateChipRow.kt
new file mode 100644
index 00000000..1c748d8f
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/component/SelectedDateChipRow.kt
@@ -0,0 +1,89 @@
+package com.kikidan.sajucontents.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+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.draw.clip
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.kikidan.designsystem.R
+import com.kikidan.designsystem.theme.TodakunColor
+import com.kikidan.designsystem.theme.TodakunTypography
+import kotlinx.collections.immutable.ImmutableList
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+
+private val DateChipFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("M.d(E)", Locale.KOREAN)
+
+@Composable
+internal fun SelectedDateChipRow(
+ selectedDates: ImmutableList,
+ onDateRemove: (LocalDate) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ LazyRow(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(selectedDates, key = { it.toString() }) { date ->
+ DateChip(date = date, onRemove = { onDateRemove(date) })
+ }
+ }
+}
+
+@Composable
+private fun DateChip(
+ date: LocalDate,
+ onRemove: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val label = remember(date) { date.format(DateChipFormatter) }
+
+ Row(
+ modifier =
+ modifier
+ .border(1.dp, TodakunColor.primary300, RoundedCornerShape(99.dp))
+ .padding(horizontal = 16.dp, vertical = 6.dp)
+ .clickable(onClick = onRemove),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = label,
+ style = TodakunTypography.body2Medium,
+ color = TodakunColor.primary700,
+ )
+ Box(
+ modifier =
+ Modifier
+ .size(20.dp)
+ .clip(CircleShape)
+ .background(color = TodakunColor.gray300),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ modifier = Modifier.size(13.dp),
+ painter = painterResource(id = R.drawable.ic_close),
+ contentDescription = stringResource(id = R.string.date_fortune_date_remove),
+ tint = TodakunColor.white,
+ )
+ }
+ }
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/CompatibilityEntrySideEffect.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/CompatibilityEntrySideEffect.kt
new file mode 100644
index 00000000..d35e3d0b
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/CompatibilityEntrySideEffect.kt
@@ -0,0 +1,14 @@
+package com.kikidan.sajucontents.model
+
+sealed interface CompatibilityEntrySideEffect {
+ data object ShowLoadError : CompatibilityEntrySideEffect
+
+ data object ShowCreateError : CompatibilityEntrySideEffect
+
+ data object NavigateToPartnerForm : CompatibilityEntrySideEffect
+
+ data class NavigateToResult(
+ val compatibilityId: String,
+ val partnerLinkId: String,
+ ) : CompatibilityEntrySideEffect
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/CompatibilityInputState.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/CompatibilityInputState.kt
new file mode 100644
index 00000000..d1ddc1dc
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/CompatibilityInputState.kt
@@ -0,0 +1,23 @@
+package com.kikidan.sajucontents.model
+
+import com.kikidan.domain.model.saju.PartnerSaju
+import com.kikidan.domain.model.saju.SajuPillarDetail
+import com.kikidan.domain.model.user.User
+import kotlinx.collections.immutable.PersistentList
+import kotlinx.collections.immutable.persistentListOf
+
+data class CompatibilityInputState(
+ val isLoading: Boolean = false,
+ val myUser: User? = null,
+ val myPillars: PersistentList = persistentListOf(),
+ val selectedPartner: PartnerSaju? = null,
+ val selectedPartnerPillars: PersistentList = persistentListOf(),
+ val isCreating: Boolean = false,
+ val partnerPicker: PartnerPickerState = PartnerPickerState(),
+)
+
+data class PartnerPickerState(
+ val isVisible: Boolean = false,
+ val isLoading: Boolean = false,
+ val partners: PersistentList = persistentListOf(),
+)
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/CompatibilityPartnerFormState.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/CompatibilityPartnerFormState.kt
new file mode 100644
index 00000000..475c0a02
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/CompatibilityPartnerFormState.kt
@@ -0,0 +1,26 @@
+package com.kikidan.sajucontents.model
+
+import com.kikidan.domain.model.user.BirthTime
+import com.kikidan.domain.model.user.DateType
+import com.kikidan.domain.model.user.Gender
+import java.time.LocalDate
+
+data class CompatibilityPartnerFormState(
+ val name: String = "",
+ val nameErrorMessageRes: Int? = null,
+ val gender: Gender = Gender.MALE,
+ val dateType: DateType = DateType.SOLAR,
+ val birthDate: LocalDate? = null,
+ val birthTime: BirthTime = BirthTime.UNKNOWN,
+ val relationshipTypeCode: String = CompatibilityRelationshipType.LOVER.code,
+ val isSaving: Boolean = false,
+) {
+ val isSaveEnabled: Boolean
+ get() = name.isNotBlank() && nameErrorMessageRes == null && birthDate != null && !isSaving
+}
+
+sealed interface CompatibilityPartnerFormSideEffect {
+ data object NavigateBack : CompatibilityPartnerFormSideEffect
+
+ data object ShowSaveError : CompatibilityPartnerFormSideEffect
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/CompatibilityRelationshipType.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/CompatibilityRelationshipType.kt
new file mode 100644
index 00000000..2107c43d
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/CompatibilityRelationshipType.kt
@@ -0,0 +1,13 @@
+package com.kikidan.sajucontents.model
+
+import androidx.annotation.StringRes
+import com.kikidan.designsystem.R
+
+enum class CompatibilityRelationshipType(
+ val code: String,
+ @param:StringRes val labelRes: Int,
+) {
+ LOVER("LOVER", R.string.partner_relationship_lover),
+ FRIEND("FRIEND", R.string.partner_relationship_friend),
+ COLLEAGUE("COLLEAGUE", R.string.partner_relationship_colleague),
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/CompatibilityResultState.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/CompatibilityResultState.kt
new file mode 100644
index 00000000..211fa766
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/CompatibilityResultState.kt
@@ -0,0 +1,27 @@
+package com.kikidan.sajucontents.model
+
+import com.kikidan.domain.model.compatibility.Compatibility
+import com.kikidan.domain.model.saju.SajuPillarDetail
+import kotlinx.collections.immutable.PersistentList
+import kotlinx.collections.immutable.persistentListOf
+
+sealed interface CompatibilityResultState {
+ data object Loading : CompatibilityResultState
+
+ data class Success(
+ val compatibilityResult: Compatibility,
+ val myPillars: PersistentList = persistentListOf(),
+ val partnerPillars: PersistentList = persistentListOf(),
+ val isShareDialogVisible: Boolean = false,
+ ) : CompatibilityResultState
+
+ data object Failure : CompatibilityResultState
+}
+
+sealed interface CompatibilityResultSideEffect {
+ data object ShowError : CompatibilityResultSideEffect
+
+ data object ShowShareError : CompatibilityResultSideEffect
+
+ data object ShowUrlCopied : CompatibilityResultSideEffect
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/DateFortuneResultSideEffect.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/DateFortuneResultSideEffect.kt
new file mode 100644
index 00000000..a29ec318
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/DateFortuneResultSideEffect.kt
@@ -0,0 +1,9 @@
+package com.kikidan.sajucontents.model
+
+sealed interface DateFortuneResultSideEffect {
+ data object ShowError : DateFortuneResultSideEffect
+
+ data object ShowShareError : DateFortuneResultSideEffect
+
+ data object ShowUrlCopied : DateFortuneResultSideEffect
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/DateFortuneResultState.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/DateFortuneResultState.kt
new file mode 100644
index 00000000..51a9c5d2
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/DateFortuneResultState.kt
@@ -0,0 +1,12 @@
+package com.kikidan.sajucontents.model
+
+import com.kikidan.domain.model.dayfortune.DayFortune
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+data class DateFortuneResultState(
+ val isLoading: Boolean = false,
+ val results: ImmutableList = persistentListOf(),
+ val selectedResultIndex: Int = 0,
+ val isShareDialogVisible: Boolean = false,
+)
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/DateFortuneSideEffect.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/DateFortuneSideEffect.kt
new file mode 100644
index 00000000..cffeff1e
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/DateFortuneSideEffect.kt
@@ -0,0 +1,13 @@
+package com.kikidan.sajucontents.model
+
+sealed interface DateFortuneSideEffect {
+ data object ShowToast : DateFortuneSideEffect
+
+ data object ShowError : DateFortuneSideEffect
+
+ data class NavigateToResult(
+ val id: List,
+ ) : DateFortuneSideEffect
+
+ data object NavigateBack : DateFortuneSideEffect
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/DateFortuneState.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/DateFortuneState.kt
new file mode 100644
index 00000000..81bc47b6
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/DateFortuneState.kt
@@ -0,0 +1,20 @@
+package com.kikidan.sajucontents.model
+
+import com.kikidan.domain.model.dayfortune.DayFortunePurpose
+import com.kikidan.domain.model.user.Gender
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import java.time.LocalDate
+
+data class DateFortuneState(
+ val selectedPurpose: DayFortunePurpose? = null,
+ // ⚠️ 서버 Request에 gender 필드가 없다(블로커 B-2). UseCase 호출에는 전달하지 않는다.
+ val selectedGender: Gender? = null,
+ val selectedDates: ImmutableList = persistentListOf(),
+ val isSheetVisible: Boolean = false,
+ val isLoading: Boolean = false,
+) {
+ // selectedGender는 서버로 보내지 않는 값이라 필수 조건에서 제외한다(B-2 회귀 방지).
+ val canSubmit: Boolean
+ get() = selectedPurpose != null && selectedDates.isNotEmpty() && !isLoading
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/YearFortuneResultSideEffect.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/YearFortuneResultSideEffect.kt
new file mode 100644
index 00000000..abd1fc91
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/YearFortuneResultSideEffect.kt
@@ -0,0 +1,9 @@
+package com.kikidan.sajucontents.model
+
+sealed interface YearFortuneResultSideEffect {
+ data object ShowError : YearFortuneResultSideEffect
+
+ data object ShowShareError : YearFortuneResultSideEffect
+
+ data object ShowUrlCopied : YearFortuneResultSideEffect
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/YearFortuneResultState.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/YearFortuneResultState.kt
new file mode 100644
index 00000000..6540ec92
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/YearFortuneResultState.kt
@@ -0,0 +1,9 @@
+package com.kikidan.sajucontents.model
+
+import com.kikidan.domain.model.fortune.YearFortune
+
+data class YearFortuneResultState(
+ val fortuneResult: YearFortune? = null,
+ val isLoading: Boolean = false,
+ val isShareDialogVisible: Boolean = false,
+)
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/YearFortuneSideEffect.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/YearFortuneSideEffect.kt
new file mode 100644
index 00000000..4a1813f1
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/YearFortuneSideEffect.kt
@@ -0,0 +1,9 @@
+package com.kikidan.sajucontents.model
+
+sealed interface YearFortuneSideEffect {
+ data class NavigateToResult(
+ val id: String,
+ ) : YearFortuneSideEffect
+
+ data object ShowError : YearFortuneSideEffect
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/YearFortuneState.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/YearFortuneState.kt
new file mode 100644
index 00000000..6f15fadb
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/model/YearFortuneState.kt
@@ -0,0 +1,14 @@
+package com.kikidan.sajucontents.model
+
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toPersistentList
+import java.time.Year
+
+private val DEFAULT_YEARS: ImmutableList = (2021..2031).toList().toPersistentList()
+
+data class YearFortuneState(
+ val years: ImmutableList = DEFAULT_YEARS,
+ val selectedYear: Int = Year.now().value,
+ val currentYear: Int = Year.now().value,
+ val isLoading: Boolean = false,
+)
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/screen/CompatibilityInputScreen.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/screen/CompatibilityInputScreen.kt
new file mode 100644
index 00000000..344fa99b
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/screen/CompatibilityInputScreen.kt
@@ -0,0 +1,381 @@
+package com.kikidan.sajucontents.screen
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+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.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+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.kikidan.designsystem.component.button.PrimaryButton
+import com.kikidan.designsystem.component.button.TodakunButtonSize
+import com.kikidan.designsystem.component.header.TodakunSubHeader
+import com.kikidan.designsystem.component.wheelpicker.TodakunWheelPicker
+import com.kikidan.designsystem.component.wheelpicker.WheelPickerBottomSheet
+import com.kikidan.designsystem.component.wheelpicker.WheelPickerColumnState
+import com.kikidan.designsystem.theme.TodakunColor
+import com.kikidan.designsystem.theme.TodakunTheme
+import com.kikidan.designsystem.theme.TodakunTypography
+import com.kikidan.domain.model.saju.PartnerSaju
+import com.kikidan.domain.model.saju.SajuPillarDetail
+import com.kikidan.domain.model.user.Birth
+import com.kikidan.domain.model.user.BirthTime
+import com.kikidan.domain.model.user.DateType
+import com.kikidan.domain.model.user.Gender
+import com.kikidan.domain.model.user.Job
+import com.kikidan.domain.model.user.RelationshipStatus
+import com.kikidan.domain.model.user.User
+import com.kikidan.sajucontents.R
+import com.kikidan.sajucontents.component.CompatibilitySajuPillars
+import com.kikidan.sajucontents.model.CompatibilityInputState
+import com.kikidan.sajucontents.model.PartnerPickerState
+import kotlinx.collections.immutable.ImmutableList
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+import com.kikidan.designsystem.R as DesignSystemR
+
+@Composable
+internal fun CompatibilityInputScreen(
+ state: CompatibilityInputState,
+ onBackClick: () -> Unit,
+ onChangeMyInfo: () -> Unit,
+ onOpenPartnerPicker: () -> Unit,
+ onDismissPartnerPicker: () -> Unit,
+ onAddNewPartner: () -> Unit,
+ onSelectPartner: (String) -> Unit,
+ onCheckCompatibilityClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .background(TodakunColor.white)
+ .systemBarsPadding(),
+ ) {
+ TodakunSubHeader(
+ title = stringResource(R.string.compatibility_entry_title),
+ onBackClick = onBackClick,
+ )
+
+ Box(modifier = Modifier.weight(1f)) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 20.dp, vertical = 24.dp)
+ .padding(bottom = CompatibilityEntryDefaults.ButtonAreaHeight),
+ verticalArrangement = Arrangement.spacedBy(32.dp),
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
+ Text(
+ text = stringResource(R.string.compatibility_my_info_title),
+ style = TodakunTypography.body1Bold,
+ color = TodakunColor.gray975,
+ )
+ state.myUser?.let { user ->
+ PersonCard(
+ name = user.name,
+ gender = user.gender,
+ birth = user.birth,
+ pillars = state.myPillars,
+ onChangeClick = onChangeMyInfo,
+ )
+ }
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ Text(
+ text = stringResource(R.string.compatibility_partner_info_title),
+ style = TodakunTypography.body1Bold,
+ color = TodakunColor.gray975,
+ )
+ val partner = state.selectedPartner
+ if (partner == null) {
+ AddPartnerPrompt(onClick = onOpenPartnerPicker)
+ } else {
+ PersonCard(
+ name = partner.name,
+ gender = partner.gender,
+ birth = partner.birth,
+ pillars = state.selectedPartnerPillars,
+ onChangeClick = onOpenPartnerPicker,
+ )
+ }
+ }
+ }
+
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier =
+ Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(horizontal = 20.dp, vertical = 12.dp),
+ ) {
+ PrimaryButton(
+ text = stringResource(R.string.compatibility_check_cta),
+ onClick = onCheckCompatibilityClick,
+ enabled = state.selectedPartner != null && !state.isCreating,
+ size = TodakunButtonSize.Large,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ }
+ val selectedIndex = state.partnerPicker.partners.indexOf(state.selectedPartner)
+ if (state.partnerPicker.isVisible) {
+ PartnerPickerSheet(
+ selectedIndex = if (selectedIndex == -1) 0 else selectedIndex,
+ pickerState = state.partnerPicker,
+ onDismissRequest = onDismissPartnerPicker,
+ onAddNewPartner = onAddNewPartner,
+ onSelectPartner = onSelectPartner,
+ )
+ }
+}
+
+@Composable
+private fun PersonCard(
+ name: String,
+ gender: Gender,
+ birth: Birth,
+ pillars: ImmutableList,
+ onChangeClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .background(shape = RoundedCornerShape(16.dp), color = TodakunColor.coolGray50)
+ .padding(20.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(
+ text = stringResource(R.string.compatibility_person_name_format, name, gender.displayName()),
+ style = TodakunTypography.body2SemiBold,
+ color = TodakunColor.gray975,
+ )
+ Text(
+ text = birth.displayText(),
+ style = TodakunTypography.body3Regular,
+ color = TodakunColor.gray500,
+ )
+ }
+ Text(
+ text = stringResource(R.string.compatibility_change_button),
+ style = TodakunTypography.body3SemiBold,
+ color = TodakunColor.primary600,
+ modifier =
+ Modifier
+ .background(shape = RoundedCornerShape(8.dp), color = TodakunColor.white)
+ .border(
+ shape = RoundedCornerShape(8.dp),
+ border =
+ BorderStroke(
+ width = 1.dp,
+ color = TodakunColor.primary500,
+ ),
+ ).padding(horizontal = 20.dp, vertical = 8.dp)
+ .clickable(onClick = onChangeClick),
+ )
+ }
+ if (pillars.isNotEmpty()) {
+ HorizontalDivider(Modifier.padding(vertical = 22.dp), 1.dp, TodakunColor.blackOpacity05)
+ CompatibilitySajuPillars(
+ modifier = Modifier.fillMaxWidth(),
+ pillars = pillars,
+ )
+ }
+ }
+}
+
+@Composable
+private fun AddPartnerPrompt(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .height(128.dp)
+ .clip(RoundedCornerShape(20.dp))
+ .background(TodakunColor.coolGray50)
+ .clickable(onClick = onClick)
+ .padding(horizontal = 20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .size(48.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(TodakunColor.gray100),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ painter = painterResource(id = DesignSystemR.drawable.ic_plus),
+ contentDescription = null,
+ tint = TodakunColor.gray400,
+ )
+ }
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = stringResource(R.string.compatibility_add_partner_prompt),
+ style = TodakunTypography.body2Medium,
+ color = TodakunColor.gray500,
+ )
+ }
+}
+
+@Composable
+private fun PartnerPickerSheet(
+ selectedIndex: Int,
+ pickerState: PartnerPickerState,
+ onDismissRequest: () -> Unit,
+ onAddNewPartner: () -> Unit,
+ onSelectPartner: (String) -> Unit,
+) {
+ val items = pickerState.partners.map { "${it.name} (${it.relationshipType.label})" }
+ // 맨 위의 사용자를 선택할 떄, 바텀시트를 열었다 닫아도 선택되도록 하는 플래그
+ var isOnceSelected by remember { mutableStateOf(false) }
+
+ WheelPickerBottomSheet(onDismissRequest = {
+ if (!isOnceSelected) onSelectPartner(pickerState.partners[selectedIndex].linkId)
+ onDismissRequest()
+ }) {
+ if (!pickerState.isLoading) {
+ TodakunWheelPicker(
+ title = stringResource(R.string.compatibility_partner_picker_title),
+ onSaveClick = onAddNewPartner,
+ saveButtonLabel = stringResource(R.string.compatibility_partner_picker_add),
+ columns = listOf(WheelPickerColumnState(items = items, selectedIndex = selectedIndex)),
+ onWheelPickerColumnSelect = { _, selectedIndex ->
+ onSelectPartner(pickerState.partners[selectedIndex].linkId)
+ isOnceSelected = true
+ },
+ directInputEnabled = false,
+ )
+ } else {
+ Spacer(modifier = Modifier.height(200.dp))
+ }
+ }
+}
+
+@Composable
+private fun Gender.displayName(): String =
+ when (this) {
+ Gender.MALE -> stringResource(DesignSystemR.string.gender_male)
+ Gender.FEMALE -> stringResource(DesignSystemR.string.gender_female)
+ }
+
+@Composable
+private fun DateType.displayName(): String =
+ when (this) {
+ DateType.SOLAR -> stringResource(DesignSystemR.string.date_type_solar)
+ DateType.LUNAR -> stringResource(DesignSystemR.string.date_type_lunar)
+ }
+
+@Composable
+private fun Birth.displayText(): String {
+ val dateText = date.format(BirthDateFormatter)
+ val dateTypeText = dateType.displayName()
+
+ val start = time.startTime
+ val end = time.endTime
+ if (time == BirthTime.UNKNOWN || start == null || end == null) {
+ return stringResource(R.string.compatibility_birth_date_only_format, dateText, dateTypeText)
+ }
+
+ return stringResource(
+ R.string.compatibility_birth_format,
+ dateText,
+ dateTypeText,
+ String.format(Locale.US, "%02d:%02d", start.hour, start.minute),
+ String.format(Locale.US, "%02d:%02d", end.minusMinutes(1).hour, end.minusMinutes(1).minute),
+ time.displayName,
+ )
+}
+
+private val BirthDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd")
+
+private object CompatibilityEntryDefaults {
+ val ButtonAreaHeight = 80.dp
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CompatibilityInputScreenPreview() {
+ TodakunTheme {
+ CompatibilityInputScreen(
+ state =
+ CompatibilityInputState(
+ myUser =
+ User(
+ id = "me",
+ name = "토닥이",
+ gender = Gender.FEMALE,
+ job = Job.WORKER,
+ relationshipStatus = RelationshipStatus.SOLO,
+ birth = Birth(DateType.SOLAR, LocalDate.of(2001, 5, 30), BirthTime.O),
+ ),
+ selectedPartner =
+ PartnerSaju(
+ linkId = "partner-1",
+ name = "토실이",
+ gender = Gender.MALE,
+ relationshipType =
+ com.kikidan.domain.model.saju
+ .RelationshipType("LOVER", "연인"),
+ birth = Birth(DateType.SOLAR, LocalDate.of(2001, 5, 30), BirthTime.O),
+ ),
+ ),
+ onBackClick = {},
+ onOpenPartnerPicker = {},
+ onDismissPartnerPicker = {},
+ onAddNewPartner = {},
+ onSelectPartner = {},
+ onCheckCompatibilityClick = {},
+ onChangeMyInfo = {},
+ )
+ }
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/screen/CompatibilityResultScreen.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/screen/CompatibilityResultScreen.kt
new file mode 100644
index 00000000..3d39acf9
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/screen/CompatibilityResultScreen.kt
@@ -0,0 +1,472 @@
+package com.kikidan.sajucontents.screen
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberTooltipState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.kikidan.designsystem.component.TodakunChip2
+import com.kikidan.designsystem.component.TodakunTooltip
+import com.kikidan.designsystem.component.button.PrimaryButton
+import com.kikidan.designsystem.component.button.TodakunButtonSize
+import com.kikidan.designsystem.theme.TodakunColor
+import com.kikidan.designsystem.theme.TodakunTheme
+import com.kikidan.designsystem.theme.TodakunTypography
+import com.kikidan.domain.model.compatibility.Compatibility
+import com.kikidan.domain.model.compatibility.CompatibilityOhaeng
+import com.kikidan.domain.model.saju.Ohaeng
+import com.kikidan.domain.model.saju.RelationshipType
+import com.kikidan.domain.model.saju.SajuPillarDetail
+import com.kikidan.sajucontents.BuildConfig
+import com.kikidan.sajucontents.R
+import com.kikidan.sajucontents.component.CompatibilityOhaengBars
+import com.kikidan.sajucontents.component.CompatibilityScoreCard
+import com.kikidan.sajucontents.component.FortuneShareDialog
+import com.kikidan.sajucontents.component.SajuCard
+import com.kikidan.sajucontents.model.CompatibilityResultState
+import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.hazeSource
+import dev.chrisbanes.haze.rememberHazeState
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import com.kikidan.designsystem.R as DesignSystemR
+
+private fun shareUrlFor(id: String): String = "${BuildConfig.APP_LINK_HOST}/compatibility?id=$id"
+
+@Composable
+internal fun CompatibilityResultScreen(
+ state: CompatibilityResultState,
+ onBackClick: () -> Unit,
+ onShareClick: () -> Unit,
+ onShareDismiss: () -> Unit,
+ onKakaoShareFail: () -> Unit,
+ onUrlCopy: () -> Unit,
+ onAskTodakClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val hazeState = rememberHazeState()
+
+ Box(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .background(Color(0xFF00010B)),
+ ) {
+ Image(
+ painter = painterResource(id = DesignSystemR.drawable.img_result_background),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .alpha(0.5f)
+ .hazeSource(state = hazeState),
+ )
+
+ CompatibilityResultContent(
+ state = state,
+ onBackClick = onBackClick,
+ onShareClick = onShareClick,
+ onAskTodakClick = onAskTodakClick,
+ hazeState = hazeState,
+ )
+
+ if (state is CompatibilityResultState.Success && state.isShareDialogVisible) {
+ val result = state.compatibilityResult
+ FortuneShareDialog(
+ fortuneTitle = result.headline,
+ fortuneId = result.id,
+ shareUrl = shareUrlFor(result.id),
+ onKakaoShareFail = onKakaoShareFail,
+ onUrlCopy = onUrlCopy,
+ onDismiss = onShareDismiss,
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun BoxScope.CompatibilityResultContent(
+ state: CompatibilityResultState,
+ onBackClick: () -> Unit,
+ onShareClick: () -> Unit,
+ onAskTodakClick: () -> Unit,
+ hazeState: HazeState,
+ modifier: Modifier = Modifier,
+) {
+ val success = state as? CompatibilityResultState.Success
+
+ Column(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .systemBarsPadding(),
+ ) {
+ ResultHeader(
+ title = stringResource(id = R.string.compatibility_result_header_title),
+ onBackClick = onBackClick,
+ onShareClick = onShareClick,
+ shareEnabled = success != null,
+ )
+ if (success != null) {
+ val result = success.compatibilityResult
+ Column(
+ modifier =
+ Modifier
+ .weight(1f)
+ .verticalScroll(rememberScrollState())
+ .padding(top = 24.dp, bottom = 120.dp),
+ verticalArrangement = Arrangement.spacedBy(20.dp),
+ ) {
+ TitleBanner(result = result)
+ CompatibilityScoreCard(
+ score = result.score,
+ subheadline = result.subheadline,
+ summary = result.summary,
+ hazeState = hazeState,
+ )
+ if (success.myPillars.isNotEmpty() && success.partnerPillars.isNotEmpty()) {
+ SajuStructureSection(
+ partnerName = result.partnerName,
+ myPillars = success.myPillars,
+ partnerPillars = success.partnerPillars,
+ )
+ }
+ OhaengSection(ohaengs = result.ohaengs)
+ TotalAnalysisSection(totalAnalysis = result.totalAnalysis, analysisBasis = result.analysisBasis)
+ }
+ }
+ }
+
+ if (success != null) {
+ val tooltipState = rememberTooltipState(isPersistent = true)
+ LaunchedEffect(tooltipState) {
+ tooltipState.show()
+ }
+
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier =
+ Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(horizontal = 20.dp, vertical = 12.dp),
+ ) {
+ TodakunTooltip(
+ text = stringResource(id = R.string.compatibility_ask_todak_tooltip),
+ state = tooltipState,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ PrimaryButton(
+ text = stringResource(id = R.string.compatibility_ask_todak),
+ onClick = onAskTodakClick,
+ size = TodakunButtonSize.Large,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ResultHeader(
+ title: String,
+ onBackClick: () -> Unit,
+ onShareClick: () -> Unit,
+ shareEnabled: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .height(48.dp)
+ .padding(horizontal = 20.dp),
+ ) {
+ Icon(
+ painter = painterResource(id = DesignSystemR.drawable.ic_chevron_left),
+ contentDescription = stringResource(id = DesignSystemR.string.header_back_content_description),
+ tint = TodakunColor.white,
+ modifier =
+ Modifier
+ .align(Alignment.CenterStart)
+ .size(20.dp)
+ .clickable(onClick = onBackClick),
+ )
+ Text(
+ text = title,
+ style = TodakunTypography.body2SemiBold,
+ color = TodakunColor.white,
+ modifier = Modifier.align(Alignment.Center),
+ )
+// Icon(
+// painter = painterResource(id = DesignSystemR.drawable.ic_share),
+// contentDescription = stringResource(id = R.string.compatibility_share),
+// tint = if (shareEnabled) TodakunColor.white else TodakunColor.whiteOpacity60,
+// modifier =
+// Modifier
+// .align(Alignment.CenterEnd)
+// .size(20.dp)
+// .clickable(enabled = shareEnabled, onClick = onShareClick),
+// )
+ }
+}
+
+@Composable
+private fun TitleBanner(result: Compatibility) {
+ Column {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
+ ) {
+ TodakunChip2(text = result.relationshipType.label)
+ Text(
+ text = stringResource(id = R.string.compatibility_partner_and_me_format, result.partnerName),
+ style = TodakunTypography.body2Medium,
+ color = TodakunColor.primary300,
+ )
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ text = result.headline,
+ style = TodakunTypography.heading3Bold,
+ color = TodakunColor.white,
+ )
+ }
+}
+
+@Composable
+private fun SajuStructureSection(
+ partnerName: String,
+ myPillars: ImmutableList,
+ partnerPillars: ImmutableList,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(TodakunColor.whiteOpacity10)
+ .padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(20.dp),
+ ) {
+ Text(
+ text = stringResource(id = R.string.compatibility_saju_structure_title),
+ style = TodakunTypography.body1Bold,
+ color = TodakunColor.white,
+ )
+ PersonPillarRow(label = partnerName, myPillars = myPillars, partnerPillars)
+ }
+}
+
+@Composable
+private fun PersonPillarRow(
+ label: String,
+ myPillars: ImmutableList,
+ partnerPillars: ImmutableList,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ PillarTableRow(labelText = null) {
+ sajuLabelString.forEach { labelRes ->
+ Text(
+ text = stringResource(labelRes),
+ style = TodakunTypography.caption3Regular,
+ color = TodakunColor.whiteOpacity90,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ PillarTableRow(labelText = stringResource(id = R.string.compatibility_saju_structure_me)) {
+ PillarCells(pillars = myPillars)
+ }
+ PillarTableRow(labelText = label) {
+ PillarCells(pillars = partnerPillars)
+ }
+ }
+}
+
+@Composable
+private fun RowScope.PillarCells(pillars: ImmutableList) {
+ (0..3).forEach { i ->
+ if (pillars.size > i) {
+ SajuCard(
+ hanja = pillars[i].cheonGan.hanja,
+ reading = pillars[i].cheonGan.displayName,
+ ohaeng = pillars[i].cheonGan.ohaeng,
+ modifier = Modifier.weight(1f),
+ )
+ } else {
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ }
+}
+
+@Composable
+private fun PillarTableRow(
+ labelText: String?,
+ modifier: Modifier = Modifier,
+ content: @Composable RowScope.() -> Unit,
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = labelText.orEmpty(),
+ style = TodakunTypography.body1Medium,
+ color = TodakunColor.white,
+ modifier = Modifier.width(PillarTableLabelWidth),
+ )
+ Row(
+ modifier = Modifier.weight(1f),
+ horizontalArrangement = Arrangement.spacedBy(22.dp),
+ ) {
+ content()
+ }
+ }
+}
+
+private val PillarTableLabelWidth = 44.dp
+
+private val sajuLabelString =
+ persistentListOf(
+ R.string.compatibility_saju_time_label,
+ R.string.compatibility_saju_day_label,
+ R.string.compatibility_saju_month_label,
+ R.string.compatibility_saju_year_label,
+ )
+
+@Composable
+private fun OhaengSection(
+ ohaengs: List,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(TodakunColor.whiteOpacity10)
+ .padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(20.dp),
+ ) {
+ Text(
+ text = stringResource(id = R.string.compatibility_ohaeng_title),
+ style = TodakunTypography.body1Bold,
+ color = TodakunColor.white,
+ )
+ CompatibilityOhaengBars(ohaengs = ohaengs)
+ }
+}
+
+@Composable
+private fun TotalAnalysisSection(
+ totalAnalysis: String,
+ analysisBasis: String,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(TodakunColor.whiteOpacity10)
+ .padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(
+ text = stringResource(id = R.string.compatibility_total_analysis_title),
+ style = TodakunTypography.body1Bold,
+ color = TodakunColor.white,
+ )
+ Text(text = totalAnalysis, style = TodakunTypography.body2Regular, color = TodakunColor.white)
+ HorizontalDivider(thickness = 1.dp, color = TodakunColor.whiteOpacity10)
+ Text(
+ text = stringResource(id = R.string.compatibility_analysis_basis_format, analysisBasis),
+ style = TodakunTypography.caption1Regular,
+ color = TodakunColor.whiteOpacity60,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CompatibilityResultScreenPreview() {
+ val sample =
+ Compatibility(
+ id = "preview-id",
+ partnerName = "토실이",
+ relationshipType = RelationshipType("LOVER", "연인"),
+ score = 85,
+ headline = "함께할 수록 빛나는 궁합",
+ subheadline = "함께 있을 때, 편안함이 커지는 사이예요.",
+ summary = "두 분은 서로의 부족한 기운을 보완하며 평온한 안식처가 되어주는 최상의 흐름을 가지고 있습니다.",
+ totalAnalysis = "두 분의 사주는 서로를 보완하는 좋은 흐름을 보이고 있어요.",
+ analysisBasis = "사주 팔자 기반",
+ ohaengs =
+ listOf(
+ CompatibilityOhaeng(Ohaeng.MOK, 25),
+ CompatibilityOhaeng(Ohaeng.HWA, 30),
+ CompatibilityOhaeng(Ohaeng.TO, 15),
+ CompatibilityOhaeng(Ohaeng.GEUM, 15),
+ CompatibilityOhaeng(Ohaeng.SU, 15),
+ ),
+ )
+ TodakunTheme {
+ CompatibilityResultScreen(
+ state = CompatibilityResultState.Success(compatibilityResult = sample),
+ onBackClick = {},
+ onShareClick = {},
+ onShareDismiss = {},
+ onKakaoShareFail = {},
+ onUrlCopy = {},
+ onAskTodakClick = {},
+ )
+ }
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/screen/DateFortuneInputScreen.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/screen/DateFortuneInputScreen.kt
new file mode 100644
index 00000000..42508c00
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/screen/DateFortuneInputScreen.kt
@@ -0,0 +1,209 @@
+package com.kikidan.sajucontents.screen
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.kikidan.designsystem.R
+import com.kikidan.designsystem.component.TodakunChip
+import com.kikidan.designsystem.component.TodakunSelectField
+import com.kikidan.designsystem.component.button.PrimaryButton
+import com.kikidan.designsystem.component.button.TodakunButtonSize
+import com.kikidan.designsystem.component.header.TodakunSubHeader
+import com.kikidan.designsystem.theme.TodakunColor
+import com.kikidan.designsystem.theme.TodakunTheme
+import com.kikidan.designsystem.theme.TodakunTypography
+import com.kikidan.domain.model.dayfortune.DayFortunePurpose
+import com.kikidan.domain.usecase.DateFortuneDefaults
+import com.kikidan.sajucontents.component.DateSelectBottomSheet
+import com.kikidan.sajucontents.model.DateFortuneState
+import kotlinx.collections.immutable.persistentListOf
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+
+private val DateSummaryFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("M.d(E)", Locale.KOREAN)
+
+@Composable
+internal fun DateFortuneInputScreen(
+ state: DateFortuneState,
+ onPurposeSelect: (DayFortunePurpose) -> Unit,
+ onOpenDateSheet: () -> Unit,
+ onCloseDateSheet: () -> Unit,
+ onDateToggle: (LocalDate) -> Unit,
+ onDateRemove: (LocalDate) -> Unit,
+ onReset: () -> Unit,
+ onSubmit: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .background(TodakunColor.white)
+ .systemBarsPadding(),
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()),
+ ) {
+ TodakunSubHeader(
+ title = stringResource(R.string.date_fortune_header_title),
+ )
+ Column(modifier = Modifier.padding(horizontal = 20.dp)) {
+ Spacer(modifier = Modifier.height(24.dp))
+ Text(
+ text = stringResource(id = R.string.date_fortune_title),
+ style = TodakunTypography.heading3Bold,
+ color = TodakunColor.gray975,
+ )
+
+ Spacer(modifier = Modifier.height(20.dp))
+
+ PurposeChipGroup(
+ selectedPurpose = state.selectedPurpose,
+ onPurposeSelect = onPurposeSelect,
+ )
+
+ Spacer(modifier = Modifier.height(30.dp))
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(id = R.string.date_fortune_dates_label),
+ style = TodakunTypography.heading3Bold,
+ color = TodakunColor.gray975,
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Text(
+ text =
+ stringResource(
+ id = R.string.date_fortune_dates_sub_label,
+ DateFortuneDefaults.MAX_TARGET_DATES,
+ ),
+ style = TodakunTypography.body3Regular,
+ color = TodakunColor.gray700,
+ )
+ }
+ Spacer(modifier = Modifier.height(20.dp))
+
+ TodakunSelectField(
+ value = state.selectedDates.joinToString(", ") { it.format(DateSummaryFormatter) },
+ onClick = onOpenDateSheet,
+ onClear = onReset,
+ placeholder = stringResource(id = R.string.date_fortune_dates_placeholder),
+ expanded = state.isSheetVisible,
+ )
+ }
+ }
+
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(
+ horizontal = 20.dp,
+ vertical = 14.dp,
+ ).align(Alignment.BottomCenter),
+ ) {
+ PrimaryButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(id = R.string.date_fortune_cta),
+ onClick = onSubmit,
+ size = TodakunButtonSize.Large,
+ enabled = state.canSubmit,
+ )
+ }
+
+ if (state.isSheetVisible) {
+ DateSelectBottomSheet(
+ selectedDates = state.selectedDates,
+ onDateToggle = onDateToggle,
+ onDateRemove = onDateRemove,
+ onReset = onReset,
+ onConfirm = onCloseDateSheet,
+ onDismissRequest = onCloseDateSheet,
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun PurposeChipGroup(
+ selectedPurpose: DayFortunePurpose?,
+ onPurposeSelect: (DayFortunePurpose) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ FlowRow(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ DayFortunePurpose.entries.forEach { purpose ->
+ TodakunChip(
+ label = purpose.label(),
+ selected = purpose == selectedPurpose,
+ onClick = { onPurposeSelect(purpose) },
+ )
+ }
+ }
+}
+
+@Composable
+fun DayFortunePurpose.label(): String =
+ stringResource(
+ id =
+ when (this) {
+ DayFortunePurpose.CONTRACT_MOVING -> R.string.date_fortune_purpose_contract_moving
+ DayFortunePurpose.BUSINESS_OPENING -> R.string.date_fortune_purpose_business_opening
+ DayFortunePurpose.TRAVEL -> R.string.date_fortune_purpose_travel
+ DayFortunePurpose.CONFESSION_DATING -> R.string.date_fortune_purpose_confession_dating
+ DayFortunePurpose.EXAM_INTERVIEW -> R.string.date_fortune_purpose_exam_interview
+ },
+ )
+
+@Preview(showBackground = true)
+@Composable
+private fun DateFortuneInputScreenPreview() {
+ TodakunTheme {
+ DateFortuneInputScreen(
+ state =
+ DateFortuneState(
+ selectedPurpose = DayFortunePurpose.TRAVEL,
+ selectedDates = persistentListOf(LocalDate.now()),
+ ),
+ onPurposeSelect = {},
+ onOpenDateSheet = {},
+ onCloseDateSheet = {},
+ onDateToggle = {},
+ onDateRemove = {},
+ onReset = {},
+ onSubmit = {},
+ )
+ }
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/screen/DateFortuneResultScreen.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/screen/DateFortuneResultScreen.kt
new file mode 100644
index 00000000..ec30f85f
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/screen/DateFortuneResultScreen.kt
@@ -0,0 +1,371 @@
+package com.kikidan.sajucontents.screen
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBarsPadding
+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.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberTooltipState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.kikidan.designsystem.R
+import com.kikidan.designsystem.component.TodakunChip2
+import com.kikidan.designsystem.component.TodakunTooltip
+import com.kikidan.designsystem.component.button.PrimaryButton
+import com.kikidan.designsystem.component.button.TodakunButtonSize
+import com.kikidan.designsystem.theme.TodakunColor
+import com.kikidan.designsystem.theme.TodakunTheme
+import com.kikidan.designsystem.theme.TodakunTypography
+import com.kikidan.domain.model.dayfortune.DayFortune
+import com.kikidan.domain.model.dayfortune.DayFortunePurpose
+import com.kikidan.domain.model.fortune.FortuneCategory
+import com.kikidan.domain.model.fortune.FortuneCategoryStar
+import com.kikidan.sajucontents.BuildConfig
+import com.kikidan.sajucontents.component.FortuneScoreCard
+import com.kikidan.sajucontents.component.FortuneShareDialog
+import com.kikidan.sajucontents.component.ResultDateTabRow
+import com.kikidan.sajucontents.model.DateFortuneResultState
+import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.hazeEffect
+import dev.chrisbanes.haze.hazeSource
+import dev.chrisbanes.haze.rememberHazeState
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import java.time.LocalDate
+
+private fun shareUrlFor(id: String): String = "${BuildConfig.APP_LINK_HOST}/day-fortune?id=$id"
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun DateFortuneResultScreen(
+ state: DateFortuneResultState,
+ onBackClick: () -> Unit,
+ onTabSelect: (Int) -> Unit,
+ onShareClick: () -> Unit,
+ onShareDismiss: () -> Unit,
+ onKakaoShareFail: () -> Unit,
+ onUrlCopy: () -> Unit,
+ onExportClick: () -> Unit,
+ onAskTodakClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val selectedFortune = state.results.getOrNull(state.selectedResultIndex)
+ val hazeState = rememberHazeState()
+
+ Box(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .background(Color(0xFF00010B)),
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.img_result_background),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .alpha(0.5f)
+ .hazeSource(state = hazeState),
+ )
+
+ if (selectedFortune == null) return@Box
+
+ DateFortuneResultContent(
+ onBackClick = onBackClick,
+ onExportClick = onExportClick,
+ onShareClick = onShareClick,
+ onTabSelect = onTabSelect,
+ onAskTodakClick = onAskTodakClick,
+ state = state,
+ selectedFortune = selectedFortune,
+ hazeState = hazeState,
+ )
+
+ if (state.isShareDialogVisible) {
+ FortuneShareDialog(
+ fortuneTitle = selectedFortune.title,
+ fortuneId = selectedFortune.id,
+ shareUrl = shareUrlFor(selectedFortune.id),
+ onKakaoShareFail = onKakaoShareFail,
+ onUrlCopy = onUrlCopy,
+ onDismiss = onShareDismiss,
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun BoxScope.DateFortuneResultContent(
+ onBackClick: () -> Unit,
+ onExportClick: () -> Unit,
+ onShareClick: () -> Unit,
+ onTabSelect: (Int) -> Unit,
+ onAskTodakClick: () -> Unit,
+ state: DateFortuneResultState,
+ selectedFortune: DayFortune,
+ hazeState: HazeState,
+ modifier: Modifier = Modifier,
+) {
+ val tooltipState = rememberTooltipState(isPersistent = true)
+ LaunchedEffect(Unit) { tooltipState.show() }
+
+ Column(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .systemBarsPadding(),
+ ) {
+ ResultHeader(
+ title = stringResource(id = R.string.date_fortune_result_title),
+ onBackClick = onBackClick,
+ onExportClick = onExportClick,
+ onShareClick = onShareClick,
+ )
+ Column(
+ modifier =
+ Modifier
+ .weight(1f)
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 20.dp)
+ .padding(top = 24.dp, bottom = 120.dp),
+ verticalArrangement = Arrangement.spacedBy(20.dp),
+ ) {
+ ResultHeadline(
+ purposeLabel = selectedFortune.purpose.label(),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ ResultDateTabRow(
+ results = state.results,
+ selectedIndex = state.selectedResultIndex,
+ onTabSelect = onTabSelect,
+ )
+ FortuneScoreCard(
+ score = selectedFortune.score,
+ title = selectedFortune.title,
+ categoryStars = selectedFortune.categoryStars.toImmutableList(),
+ hazeState = hazeState,
+ )
+ SummaryCard(content = selectedFortune.content, hazeState = hazeState)
+ }
+ }
+
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(horizontal = 20.dp, vertical = 12.dp),
+ ) {
+ TodakunTooltip(
+ text = stringResource(id = R.string.date_fortune_ask_time_hint),
+ state = tooltipState,
+ ) {
+ PrimaryButton(
+ text = stringResource(id = R.string.date_fortune_ask_todak),
+ onClick = onAskTodakClick,
+ size = TodakunButtonSize.Large,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+}
+
+@Composable
+private fun ResultHeader(
+ title: String,
+ onBackClick: () -> Unit,
+ onExportClick: () -> Unit,
+ onShareClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .height(48.dp)
+ .padding(horizontal = 20.dp),
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_chevron_left),
+ contentDescription = stringResource(id = R.string.header_back_content_description),
+ tint = TodakunColor.white,
+ modifier =
+ Modifier
+ .align(Alignment.CenterStart)
+ .size(20.dp)
+ .clickable(onClick = onBackClick),
+ )
+ Text(
+ text = title,
+ style = TodakunTypography.body2SemiBold,
+ color = TodakunColor.white,
+ modifier = Modifier.align(Alignment.Center),
+ )
+ Row(
+ modifier = Modifier.align(Alignment.CenterEnd),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_event_export),
+ contentDescription = stringResource(id = R.string.date_fortune_export_calendar),
+ tint = TodakunColor.white,
+ modifier =
+ Modifier
+ .size(20.dp)
+ .clickable(onClick = onExportClick),
+ )
+ Icon(
+ painter = painterResource(id = R.drawable.ic_share),
+ contentDescription = stringResource(id = R.string.date_fortune_share),
+ tint = TodakunColor.white,
+ modifier =
+ Modifier
+ .size(20.dp)
+ .clickable(onClick = onShareClick),
+ )
+ }
+ }
+}
+
+@Composable
+private fun ResultHeadline(
+ purposeLabel: String,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ TodakunChip2(text = purposeLabel)
+
+ val subtitle = stringResource(id = R.string.date_fortune_result_subtitle)
+ val highlight = stringResource(id = R.string.date_fortune_result_highlight)
+ val annotatedSubtitle =
+ remember(subtitle, highlight) {
+ val start = subtitle.indexOf(highlight)
+ buildAnnotatedString {
+ if (start < 0) {
+ append(subtitle)
+ } else {
+ append(subtitle.substring(0, start))
+ withStyle(SpanStyle(color = TodakunColor.primary200)) {
+ append(highlight)
+ }
+ append(subtitle.substring(start + highlight.length))
+ }
+ }
+ }
+ Text(
+ text = annotatedSubtitle,
+ style = TodakunTypography.heading3Bold,
+ color = TodakunColor.white,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
+@Composable
+private fun SummaryCard(
+ content: String,
+ hazeState: HazeState,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .hazeEffect(state = hazeState) { blurRadius = 20.dp }
+ .background(TodakunColor.whiteOpacity10)
+ .padding(horizontal = 20.dp, vertical = 24.dp),
+ verticalArrangement = Arrangement.spacedBy(20.dp),
+ ) {
+ Text(
+ text = stringResource(id = R.string.date_fortune_summary_title),
+ style = TodakunTypography.body1Bold,
+ color = TodakunColor.white,
+ )
+ Text(
+ text = content,
+ style = TodakunTypography.body2Regular,
+ color = TodakunColor.white,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun DateFortuneResultScreenPreview() {
+ val sampleFortune =
+ DayFortune(
+ id = "preview-id",
+ purpose = DayFortunePurpose.TRAVEL,
+ targetDate = LocalDate.now(),
+ score = 85,
+ title = "이 날짜엔 새로운 시작이 아주 잘 맞아요.",
+ content = "요약 분석 예시 본문입니다.",
+ categoryStars =
+ listOf(
+ FortuneCategoryStar(
+ category = FortuneCategory.RELATIONSHIP,
+ star = 3,
+ ),
+ ),
+ )
+ TodakunTheme {
+ DateFortuneResultScreen(
+ state =
+ DateFortuneResultState(
+ isLoading = false,
+ results = persistentListOf(sampleFortune),
+ selectedResultIndex = 0,
+ ),
+ onBackClick = {},
+ onTabSelect = {},
+ onShareClick = {},
+ onShareDismiss = {},
+ onKakaoShareFail = {},
+ onUrlCopy = {},
+ onExportClick = {},
+ onAskTodakClick = {},
+ )
+ }
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/screen/YearFortuneInputScreen.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/screen/YearFortuneInputScreen.kt
new file mode 100644
index 00000000..d6500e43
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/screen/YearFortuneInputScreen.kt
@@ -0,0 +1,114 @@
+package com.kikidan.sajucontents.screen
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.GridItemSpan
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.material3.Text
+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.kikidan.designsystem.component.TodakunSelectBox
+import com.kikidan.designsystem.component.button.PrimaryButton
+import com.kikidan.designsystem.component.button.TodakunButtonSize
+import com.kikidan.designsystem.component.header.TodakunSubHeader
+import com.kikidan.designsystem.theme.TodakunColor
+import com.kikidan.designsystem.theme.TodakunTheme
+import com.kikidan.designsystem.theme.TodakunTypography
+import com.kikidan.sajucontents.R
+import com.kikidan.sajucontents.model.YearFortuneState
+
+@Composable
+internal fun YearFortuneInputScreen(
+ state: YearFortuneState,
+ onBackClick: () -> Unit,
+ onYearSelect: (Int) -> Unit,
+ onSubmit: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .background(TodakunColor.white)
+ .systemBarsPadding(),
+ ) {
+ TodakunSubHeader(
+ title = stringResource(id = R.string.year_fortune_header_title),
+ onBackClick = onBackClick,
+ )
+ Box(modifier = Modifier.weight(1f).padding(horizontal = 20.dp)) {
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(2),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ item(span = { GridItemSpan(maxLineSpan) }) {
+ Text(
+ text = stringResource(id = R.string.year_fortune_select_title),
+ style = TodakunTypography.heading3Bold,
+ color = TodakunColor.gray975,
+ modifier =
+ Modifier
+ .padding(top = 24.dp, bottom = 35.dp),
+ )
+ }
+ items(state.years) { year ->
+ TodakunSelectBox(
+ text =
+ if (year == state.currentYear) {
+ stringResource(id = R.string.year_fortune_year_this_year, year)
+ } else {
+ year.toString()
+ },
+ selected = year == state.selectedYear,
+ onClick = { onYearSelect(year) },
+ modifier =
+ Modifier
+ .fillMaxWidth(),
+ )
+ }
+ }
+ }
+
+ Box(
+ modifier =
+ Modifier.padding(
+ vertical = 14.dp,
+ horizontal = 20.dp,
+ ),
+ ) {
+ PrimaryButton(
+ text = stringResource(id = R.string.year_fortune_cta),
+ onClick = onSubmit,
+ size = TodakunButtonSize.Large,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun YearFortuneInputScreenPreview() {
+ TodakunTheme {
+ YearFortuneInputScreen(
+ state = YearFortuneState(selectedYear = 2026, currentYear = 2026),
+ onBackClick = {},
+ onYearSelect = {},
+ onSubmit = {},
+ )
+ }
+}
diff --git a/feature/saju-contents/src/main/java/com/kikidan/sajucontents/screen/YearFortuneResultScreen.kt b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/screen/YearFortuneResultScreen.kt
new file mode 100644
index 00000000..4727dc4b
--- /dev/null
+++ b/feature/saju-contents/src/main/java/com/kikidan/sajucontents/screen/YearFortuneResultScreen.kt
@@ -0,0 +1,300 @@
+package com.kikidan.sajucontents.screen
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.kikidan.designsystem.component.TodakunChip2
+import com.kikidan.designsystem.component.button.PrimaryButton
+import com.kikidan.designsystem.component.button.TodakunButtonSize
+import com.kikidan.designsystem.theme.TodakunColor
+import com.kikidan.designsystem.theme.TodakunTheme
+import com.kikidan.designsystem.theme.TodakunTypography
+import com.kikidan.domain.model.fortune.FortuneCategory
+import com.kikidan.domain.model.fortune.FortuneCategoryStar
+import com.kikidan.domain.model.fortune.YearFortune
+import com.kikidan.domain.util.yearToGanji
+import com.kikidan.sajucontents.BuildConfig
+import com.kikidan.sajucontents.R
+import com.kikidan.sajucontents.component.FortuneScoreCard
+import com.kikidan.sajucontents.component.FortuneShareDialog
+import com.kikidan.sajucontents.model.YearFortuneResultState
+import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.hazeEffect
+import dev.chrisbanes.haze.hazeSource
+import dev.chrisbanes.haze.rememberHazeState
+import kotlinx.collections.immutable.toImmutableList
+import com.kikidan.designsystem.R as DesignSystemR
+
+private fun shareUrlFor(id: String): String = "${BuildConfig.APP_LINK_HOST}/year-fortune?id=$id"
+
+@Composable
+internal fun YearFortuneResultScreen(
+ state: YearFortuneResultState,
+ onBackClick: () -> Unit,
+ onShareClick: () -> Unit,
+ onShareDismiss: () -> Unit,
+ onKakaoShareFail: () -> Unit,
+ onUrlCopy: () -> Unit,
+ onAskTodakClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val fortune = state.fortuneResult
+ val hazeState = rememberHazeState()
+
+ Box(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .background(Color(0xFF00010B)),
+ ) {
+ Image(
+ painter = painterResource(id = DesignSystemR.drawable.img_result_background),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .alpha(0.5f)
+ .hazeSource(state = hazeState),
+ )
+
+ YearFortuneResultContent(
+ fortune = fortune,
+ onBackClick = onBackClick,
+ onShareClick = onShareClick,
+ onAskTodakClick = onAskTodakClick,
+ hazeState = hazeState,
+ )
+
+ if (state.isShareDialogVisible && fortune != null) {
+ FortuneShareDialog(
+ fortuneTitle = fortune.title,
+ fortuneId = fortune.id,
+ shareUrl = shareUrlFor(fortune.id),
+ onKakaoShareFail = onKakaoShareFail,
+ onUrlCopy = onUrlCopy,
+ onDismiss = onShareDismiss,
+ )
+ }
+ }
+}
+
+@Composable
+private fun BoxScope.YearFortuneResultContent(
+ fortune: YearFortune?,
+ onBackClick: () -> Unit,
+ onShareClick: () -> Unit,
+ onAskTodakClick: () -> Unit,
+ hazeState: HazeState,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .systemBarsPadding(),
+ ) {
+ ResultHeader(
+ title = stringResource(id = R.string.year_fortune_result_header_title),
+ onBackClick = onBackClick,
+ onShareClick = onShareClick,
+ shareEnabled = fortune != null,
+ )
+ if (fortune != null) {
+ Column(
+ modifier =
+ Modifier
+ .weight(1f)
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 20.dp)
+ .padding(top = 24.dp, bottom = 120.dp),
+ verticalArrangement = Arrangement.spacedBy(20.dp),
+ ) {
+ ResultHeadline(
+ ganjiLabel = yearToGanji(fortune.year),
+ title = stringResource(id = R.string.year_fortune_result_title, fortune.year),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ FortuneScoreCard(
+ score = fortune.score,
+ title = fortune.title,
+ categoryStars = fortune.categories.toImmutableList(),
+ hazeState = hazeState,
+ scoreLabel = stringResource(id = R.string.year_fortune_score_label),
+ )
+ SummaryCard(content = fortune.content, hazeState = hazeState)
+ }
+ }
+ }
+
+ if (fortune != null) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier =
+ Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(horizontal = 20.dp, vertical = 12.dp),
+ ) {
+ PrimaryButton(
+ text = stringResource(id = R.string.year_fortune_ask_todak),
+ onClick = onAskTodakClick,
+ size = TodakunButtonSize.Large,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+}
+
+@Composable
+private fun ResultHeader(
+ title: String,
+ onBackClick: () -> Unit,
+ onShareClick: () -> Unit,
+ shareEnabled: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .height(48.dp)
+ .padding(horizontal = 20.dp),
+ ) {
+ Icon(
+ painter = painterResource(id = DesignSystemR.drawable.ic_chevron_left),
+ contentDescription = stringResource(id = DesignSystemR.string.header_back_content_description),
+ tint = TodakunColor.white,
+ modifier =
+ Modifier
+ .align(Alignment.CenterStart)
+ .size(20.dp)
+ .clickable(onClick = onBackClick),
+ )
+ Text(
+ text = title,
+ style = TodakunTypography.body2SemiBold,
+ color = TodakunColor.white,
+ modifier = Modifier.align(Alignment.Center),
+ )
+ Icon(
+ painter = painterResource(id = DesignSystemR.drawable.ic_share),
+ contentDescription = stringResource(id = R.string.year_fortune_share),
+ tint = if (shareEnabled) TodakunColor.white else TodakunColor.whiteOpacity60,
+ modifier =
+ Modifier
+ .align(Alignment.CenterEnd)
+ .size(20.dp)
+ .clickable(enabled = shareEnabled, onClick = onShareClick),
+ )
+ }
+}
+
+@Composable
+private fun ResultHeadline(
+ ganjiLabel: String,
+ title: String,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ TodakunChip2(text = ganjiLabel)
+ Text(
+ text = title,
+ style = TodakunTypography.heading3Bold,
+ color = TodakunColor.white,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
+@Composable
+private fun SummaryCard(
+ content: String,
+ hazeState: HazeState,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .hazeEffect(state = hazeState) { blurRadius = 20.dp }
+ .background(TodakunColor.whiteOpacity10)
+ .padding(horizontal = 20.dp, vertical = 24.dp),
+ verticalArrangement = Arrangement.spacedBy(20.dp),
+ ) {
+ Text(
+ text = stringResource(id = R.string.year_fortune_summary_title),
+ style = TodakunTypography.body1Bold,
+ color = TodakunColor.white,
+ )
+ Text(
+ text = content,
+ style = TodakunTypography.body2Regular,
+ color = TodakunColor.white,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun YearFortuneResultScreenPreview() {
+ val sample =
+ YearFortune(
+ id = "preview-id",
+ year = 2026,
+ score = 85,
+ title = "새로운 도전이 결실을 맺는 해예요.",
+ content = "요약 분석 예시 본문입니다.",
+ categories =
+ listOf(
+ FortuneCategoryStar(FortuneCategory.ACHIEVEMENT, 3),
+ FortuneCategoryStar(FortuneCategory.MONEY, 2),
+ FortuneCategoryStar(FortuneCategory.RELATIONSHIP, 1),
+ ),
+ )
+ TodakunTheme {
+ YearFortuneResultScreen(
+ state = YearFortuneResultState(fortuneResult = sample),
+ onBackClick = {},
+ onShareClick = {},
+ onShareDismiss = {},
+ onKakaoShareFail = {},
+ onUrlCopy = {},
+ onAskTodakClick = {},
+ )
+ }
+}
diff --git a/feature/saju-contents/src/main/res/values/strings.xml b/feature/saju-contents/src/main/res/values/strings.xml
new file mode 100644
index 00000000..9f1b42fe
--- /dev/null
+++ b/feature/saju-contents/src/main/res/values/strings.xml
@@ -0,0 +1,63 @@
+
+
+
+ 연도별 운세
+ 어느 해가 궁금하시나요?
+ %1$d (올해)
+ 무료로 연도별 운세 보기
+ 연도별 운세를 불러오지 못했어요. 다시 시도해주세요.
+
+
+ 연도별 운세 결과
+ 공유
+ %1$d년 토닥이님의 운세
+ 연간 종합 점수
+ 종합 총평
+ 토닥이에게 더 물어보기
+
+
+ 상대방 궁합
+ 내 정보
+ 상대방 정보
+ %1$s・%2$s
+ 변경
+ 궁합 볼 상대방의 정보를 입력해 주세요.
+ 무료로 궁합 보기
+ %1$s %2$s
+ %1$s %2$s・%3$s~%4$s(%5$s)
+ 정보를 불러오지 못했어요. 다시 시도해주세요.
+ 궁합을 확인하지 못했어요. 다시 시도해주세요.
+
+
+ 궁합 상대 선택
+ 추가
+ 등록된 상대방 정보가 없어요.
+
+
+ 상대방 정보 입력
+ 홍길동
+ 생년월일 선택
+ 태어난 시간 선택
+ 최대 10글자까지 입력 가능해요.
+ 저장하지 못했어요. 다시 시도해주세요.
+
+
+ 상대방 궁합 결과
+ 공유
+ 궁합 점수
+ %1$s님과 나의
+ 사주 구조
+ 나
+ 오행 시각화
+ 궁합 총운 분석
+ 분석 데이터 : %1$s
+ 토닥이에게 더 물어보기
+ 오늘 이 사람과 어디를 갈까?
+ 시
+ 일
+ 월
+ 년
+
+
+
+
diff --git a/feature/saju-contents/src/test/java/com/kikidan/sajucontents/CompatibilityInputViewModelTest.kt b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/CompatibilityInputViewModelTest.kt
new file mode 100644
index 00000000..f6e56bac
--- /dev/null
+++ b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/CompatibilityInputViewModelTest.kt
@@ -0,0 +1,278 @@
+package com.kikidan.sajucontents
+
+import com.kikidan.domain.model.saju.CheonGan
+import com.kikidan.domain.model.saju.PartnerSaju
+import com.kikidan.domain.model.saju.RelationshipType
+import com.kikidan.domain.model.saju.SajuChartDetail
+import com.kikidan.domain.model.user.Birth
+import com.kikidan.domain.model.user.BirthTime
+import com.kikidan.domain.model.user.DateType
+import com.kikidan.domain.model.user.Gender
+import com.kikidan.domain.usecase.CreateCompatibilityUseCase
+import com.kikidan.domain.usecase.mypage.GetMansaeryeokDetailUseCase
+import com.kikidan.domain.usecase.saju.GetPartnerMansaeryeokDetailUseCase
+import com.kikidan.domain.usecase.saju.GetPartnerSajuChartDetailUseCase
+import com.kikidan.domain.usecase.saju.GetPartnerSajuListUseCase
+import com.kikidan.domain.usecase.saju.GetPartnerSajuUseCase
+import com.kikidan.domain.usecase.saju.GetSajuChartDetailUseCase
+import com.kikidan.domain.usecase.user.GetUserUseCase
+import com.kikidan.sajucontents.fake.FakeCompatibilityRepository
+import com.kikidan.sajucontents.fake.FakePartnerSajuRepository
+import com.kikidan.sajucontents.fake.FakeSajuRepository
+import com.kikidan.sajucontents.fake.FakeUserRepository
+import com.kikidan.sajucontents.model.CompatibilityEntrySideEffect
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.orbitmvi.orbit.test.test
+import java.time.LocalDate
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class CompatibilityInputViewModelTest {
+ private lateinit var fakeUserRepository: FakeUserRepository
+ private lateinit var fakeSajuRepository: FakeSajuRepository
+ private lateinit var fakePartnerSajuRepository: FakePartnerSajuRepository
+ private lateinit var fakeCompatibilityRepository: FakeCompatibilityRepository
+
+ @Before
+ fun setUp() {
+ // CompatibilityInputViewModel의 container(...)는 viewModelScope(Dispatchers.Main.immediate)를 쓴다.
+ Dispatchers.setMain(StandardTestDispatcher())
+ fakeUserRepository = FakeUserRepository()
+ fakeSajuRepository = FakeSajuRepository()
+ fakePartnerSajuRepository = FakePartnerSajuRepository()
+ fakeCompatibilityRepository = FakeCompatibilityRepository()
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun viewModel() =
+ CompatibilityInputViewModel(
+ getMansaeryeokDetail =
+ GetMansaeryeokDetailUseCase(
+ GetUserUseCase(fakeUserRepository),
+ GetSajuChartDetailUseCase(fakeSajuRepository),
+ ),
+ getPartnerSajuList = GetPartnerSajuListUseCase(fakePartnerSajuRepository),
+ getPartnerMansaeryeokDetail =
+ GetPartnerMansaeryeokDetailUseCase(
+ GetPartnerSajuUseCase(fakePartnerSajuRepository),
+ GetPartnerSajuChartDetailUseCase(fakePartnerSajuRepository),
+ ),
+ createCompatibility = CreateCompatibilityUseCase(fakeCompatibilityRepository),
+ )
+
+ @Test
+ fun `초기화_시_내_정보와_파트너_목록이_로드된다`() =
+ runTest {
+ fakePartnerSajuRepository.listResult = Result.success(emptyList())
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.loadMyInfo()
+ expectState { copy(isLoading = true) }
+ expectState {
+ copy(
+ isLoading = false,
+ myUser = fakeUserRepository.result.getOrThrow(),
+ myPillars = persistentListOf(),
+ partnerPicker = partnerPicker.copy(isLoading = false, partners = persistentListOf()),
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `파트너가_한_명뿐이면_자동으로_선택된다`() =
+ runTest {
+ val viewModel = viewModel()
+ val partners = fakePartnerSajuRepository.listResult.getOrThrow()
+
+ viewModel.test(this) {
+ viewModel.loadMyInfo()
+ expectState { copy(isLoading = true) }
+ expectState {
+ copy(
+ isLoading = false,
+ myUser = fakeUserRepository.result.getOrThrow(),
+ myPillars = persistentListOf(),
+ partnerPicker =
+ partnerPicker.copy(
+ isLoading = false,
+ partners = persistentListOf(*partners.toTypedArray()),
+ ),
+ )
+ }
+ expectState {
+ copy(
+ selectedPartner = fakePartnerSajuRepository.getResult.getOrThrow(),
+ selectedPartnerPillars = persistentListOf(),
+ partnerPicker = partnerPicker.copy(isVisible = false),
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `파트너가_있으면_선택_시트가_열린다`() =
+ runTest {
+ val viewModel = viewModel()
+ val partners = fakePartnerSajuRepository.listResult.getOrThrow()
+
+ viewModel.test(this) {
+ viewModel.loadMyInfo()
+ expectState { copy(isLoading = true) }
+ expectState {
+ copy(
+ isLoading = false,
+ myUser = fakeUserRepository.result.getOrThrow(),
+ myPillars = persistentListOf(),
+ partnerPicker =
+ partnerPicker.copy(
+ isLoading = false,
+ partners = persistentListOf(*partners.toTypedArray()),
+ ),
+ )
+ }
+ // 파트너가 한 명이라 자동 선택된다.
+ expectState {
+ copy(
+ selectedPartner = fakePartnerSajuRepository.getResult.getOrThrow(),
+ selectedPartnerPillars = persistentListOf(),
+ partnerPicker = partnerPicker.copy(isVisible = false),
+ )
+ }
+
+ viewModel.openPartnerPicker()
+ expectState { copy(partnerPicker = partnerPicker.copy(isVisible = true)) }
+ }
+ }
+
+ @Test
+ fun `파트너가_없으면_바로_등록_화면으로_이동한다`() =
+ runTest {
+ fakePartnerSajuRepository.listResult = Result.success(emptyList())
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.loadMyInfo()
+ expectState { copy(isLoading = true) }
+ expectState {
+ copy(
+ isLoading = false,
+ myUser = fakeUserRepository.result.getOrThrow(),
+ myPillars = persistentListOf(),
+ partnerPicker = partnerPicker.copy(isLoading = false, partners = persistentListOf()),
+ )
+ }
+
+ viewModel.openPartnerPicker()
+ expectSideEffect(CompatibilityEntrySideEffect.NavigateToPartnerForm)
+ }
+ }
+
+ @Test
+ fun `궁합_확인_성공_시_결과_화면으로_이동한다`() =
+ runTest {
+ fakePartnerSajuRepository.listResult = Result.success(emptyList())
+ fakePartnerSajuRepository.getResult =
+ Result.success(
+ PartnerSaju(
+ linkId = "partner-1",
+ name = "토실이",
+ gender = Gender.MALE,
+ relationshipType = RelationshipType("LOVER", "연인"),
+ birth = Birth(DateType.SOLAR, LocalDate.of(2001, 5, 30), BirthTime.O),
+ ),
+ )
+ fakePartnerSajuRepository.chartDetailResult =
+ Result.success(SajuChartDetail(CheonGan.GI, emptyList(), emptyList()))
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.loadMyInfo()
+ expectState { copy(isLoading = true) }
+ expectState {
+ copy(
+ isLoading = false,
+ myUser = fakeUserRepository.result.getOrThrow(),
+ myPillars = persistentListOf(),
+ partnerPicker = partnerPicker.copy(isLoading = false, partners = persistentListOf()),
+ )
+ }
+
+ viewModel.selectPartner("partner-1")
+ expectState {
+ copy(
+ selectedPartner = fakePartnerSajuRepository.getResult.getOrThrow(),
+ selectedPartnerPillars = persistentListOf(),
+ partnerPicker = partnerPicker.copy(isVisible = false),
+ )
+ }
+
+ viewModel.checkCompatibility()
+ expectState { copy(isCreating = true) }
+ expectState { copy(isCreating = false) }
+ expectSideEffect(CompatibilityEntrySideEffect.NavigateToResult("compat-1", "partner-1"))
+ }
+ assertEquals("partner-1", fakeCompatibilityRepository.lastPartnerLinkId)
+ }
+
+ @Test
+ fun `궁합_확인_실패_시_에러가_발생한다`() =
+ runTest {
+ fakePartnerSajuRepository.listResult = Result.success(emptyList())
+ fakePartnerSajuRepository.getResult =
+ Result.success(
+ PartnerSaju(
+ linkId = "partner-1",
+ name = "토실이",
+ gender = Gender.MALE,
+ relationshipType = RelationshipType("LOVER", "연인"),
+ birth = Birth(DateType.SOLAR, LocalDate.of(2001, 5, 30), BirthTime.O),
+ ),
+ )
+ fakePartnerSajuRepository.chartDetailResult =
+ Result.success(SajuChartDetail(CheonGan.GI, emptyList(), emptyList()))
+ fakeCompatibilityRepository.result = Result.failure(IllegalStateException("서버 오류"))
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.loadMyInfo()
+ expectState { copy(isLoading = true) }
+ expectState {
+ copy(
+ isLoading = false,
+ myUser = fakeUserRepository.result.getOrThrow(),
+ myPillars = persistentListOf(),
+ partnerPicker = partnerPicker.copy(isLoading = false, partners = persistentListOf()),
+ )
+ }
+
+ viewModel.selectPartner("partner-1")
+ expectState {
+ copy(
+ selectedPartner = fakePartnerSajuRepository.getResult.getOrThrow(),
+ selectedPartnerPillars = persistentListOf(),
+ partnerPicker = partnerPicker.copy(isVisible = false),
+ )
+ }
+
+ viewModel.checkCompatibility()
+ expectState { copy(isCreating = true) }
+ expectState { copy(isCreating = false) }
+ expectSideEffect(CompatibilityEntrySideEffect.ShowCreateError)
+ }
+ }
+}
diff --git a/feature/saju-contents/src/test/java/com/kikidan/sajucontents/CompatibilityPartnerFormViewModelTest.kt b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/CompatibilityPartnerFormViewModelTest.kt
new file mode 100644
index 00000000..eafd0eaa
--- /dev/null
+++ b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/CompatibilityPartnerFormViewModelTest.kt
@@ -0,0 +1,98 @@
+package com.kikidan.sajucontents
+
+import com.kikidan.domain.usecase.saju.RegisterPartnerSajuUseCase
+import com.kikidan.sajucontents.fake.FakePartnerSajuRepository
+import com.kikidan.sajucontents.model.CompatibilityPartnerFormSideEffect
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.orbitmvi.orbit.test.test
+import java.time.LocalDate
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class CompatibilityPartnerFormViewModelTest {
+ private lateinit var fakeRepository: FakePartnerSajuRepository
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(StandardTestDispatcher())
+ fakeRepository = FakePartnerSajuRepository()
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun viewModel() = CompatibilityPartnerFormViewModel(RegisterPartnerSajuUseCase(fakeRepository))
+
+ @Test
+ fun `이름이_10자를_초과하면_에러가_설정된다`() =
+ runTest {
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.updateName("가나다라마바사아자차카")
+ expectState {
+ copy(name = "가나다라마바사아자차카", nameErrorMessageRes = R.string.compatibility_partner_form_name_error)
+ }
+ }
+ }
+
+ @Test
+ fun `필수값이_모두_채워지면_저장이_가능하다`() =
+ runTest {
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.updateName("토실이")
+ expectState { copy(name = "토실이") }
+ viewModel.updateBirthDate(LocalDate.of(2001, 5, 30))
+ expectState { copy(name = "토실이", birthDate = LocalDate.of(2001, 5, 30)) }
+ }
+ }
+
+ @Test
+ fun `저장_성공_시_뒤로가기_사이드이펙트가_발생한다`() =
+ runTest {
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.updateName("토실이")
+ expectState { copy(name = "토실이") }
+ viewModel.updateBirthDate(LocalDate.of(2001, 5, 30))
+ expectState { copy(name = "토실이", birthDate = LocalDate.of(2001, 5, 30)) }
+
+ viewModel.save()
+ expectState { copy(name = "토실이", birthDate = LocalDate.of(2001, 5, 30), isSaving = true) }
+ expectState { copy(name = "토실이", birthDate = LocalDate.of(2001, 5, 30), isSaving = false) }
+ expectSideEffect(CompatibilityPartnerFormSideEffect.NavigateBack)
+ }
+ org.junit.Assert.assertEquals("토실이", fakeRepository.lastRegisterInput?.name)
+ }
+
+ @Test
+ fun `저장_실패_시_에러_사이드이펙트가_발생한다`() =
+ runTest {
+ fakeRepository.registerResult = Result.failure(IllegalStateException("서버 오류"))
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.updateName("토실이")
+ expectState { copy(name = "토실이") }
+ viewModel.updateBirthDate(LocalDate.of(2001, 5, 30))
+ expectState { copy(name = "토실이", birthDate = LocalDate.of(2001, 5, 30)) }
+
+ viewModel.save()
+ expectState { copy(name = "토실이", birthDate = LocalDate.of(2001, 5, 30), isSaving = true) }
+ expectState { copy(name = "토실이", birthDate = LocalDate.of(2001, 5, 30), isSaving = false) }
+ expectSideEffect(CompatibilityPartnerFormSideEffect.ShowSaveError)
+ }
+ }
+}
diff --git a/feature/saju-contents/src/test/java/com/kikidan/sajucontents/CompatibilityResultViewModelTest.kt b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/CompatibilityResultViewModelTest.kt
new file mode 100644
index 00000000..4db525b8
--- /dev/null
+++ b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/CompatibilityResultViewModelTest.kt
@@ -0,0 +1,126 @@
+package com.kikidan.sajucontents
+
+import com.kikidan.domain.model.saju.CheonGan
+import com.kikidan.domain.model.saju.SajuChartDetail
+import com.kikidan.domain.usecase.GetCompatibilityUseCase
+import com.kikidan.domain.usecase.saju.GetPartnerSajuChartDetailUseCase
+import com.kikidan.domain.usecase.saju.GetSajuChartDetailUseCase
+import com.kikidan.sajucontents.fake.FakeCompatibilityRepository
+import com.kikidan.sajucontents.fake.FakePartnerSajuRepository
+import com.kikidan.sajucontents.fake.FakeSajuRepository
+import com.kikidan.sajucontents.model.CompatibilityResultSideEffect
+import com.kikidan.sajucontents.model.CompatibilityResultState
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.orbitmvi.orbit.test.test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class CompatibilityResultViewModelTest {
+ private lateinit var fakeCompatibilityRepository: FakeCompatibilityRepository
+ private lateinit var fakeSajuRepository: FakeSajuRepository
+ private lateinit var fakePartnerSajuRepository: FakePartnerSajuRepository
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(StandardTestDispatcher())
+ fakeCompatibilityRepository = FakeCompatibilityRepository()
+ fakeSajuRepository = FakeSajuRepository()
+ fakePartnerSajuRepository = FakePartnerSajuRepository()
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun viewModel() =
+ CompatibilityResultViewModel(
+ getCompatibility = GetCompatibilityUseCase(fakeCompatibilityRepository),
+ getSajuChartDetail = GetSajuChartDetailUseCase(fakeSajuRepository),
+ getPartnerSajuChartDetail = GetPartnerSajuChartDetailUseCase(fakePartnerSajuRepository),
+ )
+
+ @Test
+ fun `partnerLinkId가_있으면_사주_구조까지_로드된다`() =
+ runTest {
+ fakeSajuRepository.chartDetailResult =
+ Result.success(SajuChartDetail(CheonGan.GI, emptyList(), emptyList()))
+ fakePartnerSajuRepository.chartDetailResult =
+ Result.success(SajuChartDetail(CheonGan.SIN, emptyList(), emptyList()))
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.load("compat-1", "partner-1")
+ expectState {
+ CompatibilityResultState.Success(
+ compatibilityResult = fakeCompatibilityRepository.result.getOrThrow(),
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `partnerLinkId가_없으면_사주_구조_없이_로드된다`() =
+ runTest {
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.load("compat-1", null)
+ expectState {
+ CompatibilityResultState.Success(
+ compatibilityResult = fakeCompatibilityRepository.result.getOrThrow(),
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `조회_실패_시_ShowError가_발생한다`() =
+ runTest {
+ fakeCompatibilityRepository.result = Result.failure(IllegalStateException("서버 오류"))
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.load("compat-1", null)
+ expectState { CompatibilityResultState.Failure }
+ expectSideEffect(CompatibilityResultSideEffect.ShowError)
+ }
+ }
+
+ @Test
+ fun `공유_다이얼로그_상태가_토글된다`() =
+ runTest {
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.load("compat-1", null)
+ expectState {
+ CompatibilityResultState.Success(
+ compatibilityResult = fakeCompatibilityRepository.result.getOrThrow(),
+ )
+ }
+
+ viewModel.showShareDialog()
+ expectState {
+ CompatibilityResultState.Success(
+ compatibilityResult = fakeCompatibilityRepository.result.getOrThrow(),
+ isShareDialogVisible = true,
+ )
+ }
+ viewModel.hideShareDialog()
+ expectState {
+ CompatibilityResultState.Success(
+ compatibilityResult = fakeCompatibilityRepository.result.getOrThrow(),
+ isShareDialogVisible = false,
+ )
+ }
+ }
+ }
+}
diff --git a/feature/saju-contents/src/test/java/com/kikidan/sajucontents/DateFortuneResultViewModelTest.kt b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/DateFortuneResultViewModelTest.kt
new file mode 100644
index 00000000..b962d5d8
--- /dev/null
+++ b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/DateFortuneResultViewModelTest.kt
@@ -0,0 +1,206 @@
+package com.kikidan.sajucontents
+
+import com.kikidan.domain.model.dayfortune.DayFortune
+import com.kikidan.domain.model.dayfortune.DayFortunePurpose
+import com.kikidan.domain.usecase.GetDayFortuneUseCase
+import com.kikidan.sajucontents.fake.FakeDayFortuneRepository
+import com.kikidan.sajucontents.model.DateFortuneResultSideEffect
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.orbitmvi.orbit.test.test
+import java.time.LocalDate
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class DateFortuneResultViewModelTest {
+ private lateinit var fakeRepository: FakeDayFortuneRepository
+ private lateinit var getDayFortune: GetDayFortuneUseCase
+
+ @Before
+ fun setUp() {
+ // DateFortuneResultViewModel의 container(...)는 viewModelScope(Dispatchers.Main.immediate)를 쓴다.
+ Dispatchers.setMain(StandardTestDispatcher())
+ fakeRepository = FakeDayFortuneRepository()
+ getDayFortune = GetDayFortuneUseCase(fakeRepository)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun viewModel() = DateFortuneResultViewModel(getDayFortune)
+
+ private fun fortune(
+ id: String,
+ score: Int = 85,
+ ) = DayFortune(
+ id = id,
+ purpose = DayFortunePurpose.TRAVEL,
+ targetDate = LocalDate.now(),
+ score = score,
+ title = "title-$id",
+ content = "content-$id",
+ categoryStars = emptyList(),
+ )
+
+ @Test
+ fun `결과가_3개_이하면_전부_점수_내림차순으로_남는다`() =
+ runTest {
+ val viewModel = viewModel()
+ fakeRepository.getResults["id-1"] = Result.success(fortune("id-1", score = 70))
+ fakeRepository.getResults["id-2"] = Result.success(fortune("id-2", score = 95))
+
+ viewModel.test(this) {
+ viewModel.loadResults(listOf("id-1", "id-2"))
+ expectState { copy(isLoading = true) }
+ expectState {
+ copy(
+ isLoading = false,
+ results = persistentListOf(fortune("id-2", score = 95), fortune("id-1", score = 70)),
+ selectedResultIndex = 0,
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `결과가_3개를_초과하면_점수_내림차순_상위_3개만_남는다`() =
+ runTest {
+ val viewModel = viewModel()
+ val ids = listOf("id-1", "id-2", "id-3", "id-4")
+ fakeRepository.getResults["id-1"] = Result.success(fortune("id-1", score = 70))
+ fakeRepository.getResults["id-2"] = Result.success(fortune("id-2", score = 95))
+ fakeRepository.getResults["id-3"] = Result.success(fortune("id-3", score = 60))
+ fakeRepository.getResults["id-4"] = Result.success(fortune("id-4", score = 80))
+
+ viewModel.test(this) {
+ viewModel.loadResults(ids)
+ expectState { copy(isLoading = true) }
+ expectState {
+ copy(
+ isLoading = false,
+ results =
+ persistentListOf(
+ fortune("id-2", score = 95),
+ fortune("id-4", score = 80),
+ fortune("id-1", score = 70),
+ ),
+ selectedResultIndex = 0,
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `점수가_같으면_먼저_요청한_id_순서가_유지된다`() =
+ runTest {
+ val viewModel = viewModel()
+ val ids = listOf("id-1", "id-2", "id-3")
+ fakeRepository.getResults["id-1"] = Result.success(fortune("id-1", score = 80))
+ fakeRepository.getResults["id-2"] = Result.success(fortune("id-2", score = 80))
+ fakeRepository.getResults["id-3"] = Result.success(fortune("id-3", score = 80))
+
+ viewModel.test(this) {
+ viewModel.loadResults(ids)
+ expectState { copy(isLoading = true) }
+ expectState {
+ copy(
+ isLoading = false,
+ results =
+ persistentListOf(
+ fortune("id-1", score = 80),
+ fortune("id-2", score = 80),
+ fortune("id-3", score = 80),
+ ),
+ selectedResultIndex = 0,
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `하나라도_실패하면_ShowError가_발생하고_isLoading만_false로_돌아온다`() =
+ runTest {
+ val viewModel = viewModel()
+ fakeRepository.getResults["id-1"] = Result.success(fortune("id-1"))
+ fakeRepository.getResults["id-2"] = Result.failure(IllegalStateException("서버 오류"))
+
+ viewModel.test(this) {
+ viewModel.loadResults(listOf("id-1", "id-2"))
+ expectState { copy(isLoading = true) }
+ expectState { copy(isLoading = false) }
+ expectSideEffect(DateFortuneResultSideEffect.ShowError)
+ }
+ }
+
+ @Test
+ fun `탭_선택_시_selectedResultIndex가_변경된다`() =
+ runTest {
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.selectTabResult(1)
+ expectState { copy(selectedResultIndex = 1) }
+ }
+ }
+
+ @Test
+ fun `공유_아이콘_탭_시_isShareDialogVisible이_true가_된다`() =
+ runTest {
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.showShareDialog()
+ expectState { copy(isShareDialogVisible = true) }
+ }
+ }
+
+ @Test
+ fun `취소_시_isShareDialogVisible이_false가_된다`() =
+ runTest {
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.showShareDialog()
+ expectState { copy(isShareDialogVisible = true) }
+ viewModel.hideShareDialog()
+ expectState { copy(isShareDialogVisible = false) }
+ }
+ }
+
+ @Test
+ fun `카카오_공유_실패_시_팝업이_닫히고_ShowShareError가_발생한다`() =
+ runTest {
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.showShareDialog()
+ expectState { copy(isShareDialogVisible = true) }
+ viewModel.notifyShareUnavailable()
+ expectState { copy(isShareDialogVisible = false) }
+ expectSideEffect(DateFortuneResultSideEffect.ShowShareError)
+ }
+ }
+
+ @Test
+ fun `URL_복사_시_팝업이_닫히고_ShowUrlCopied가_발생한다`() =
+ runTest {
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.showShareDialog()
+ expectState { copy(isShareDialogVisible = true) }
+ viewModel.notifyUrlCopied()
+ expectState { copy(isShareDialogVisible = false) }
+ expectSideEffect(DateFortuneResultSideEffect.ShowUrlCopied)
+ }
+ }
+}
diff --git a/feature/saju-contents/src/test/java/com/kikidan/sajucontents/DateFortuneViewModelTest.kt b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/DateFortuneViewModelTest.kt
new file mode 100644
index 00000000..15d97fd6
--- /dev/null
+++ b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/DateFortuneViewModelTest.kt
@@ -0,0 +1,188 @@
+package com.kikidan.sajucontents
+
+import com.kikidan.domain.model.dayfortune.DayFortune
+import com.kikidan.domain.model.dayfortune.DayFortunePurpose
+import com.kikidan.domain.model.user.Gender
+import com.kikidan.domain.usecase.CreateDayFortunesUseCase
+import com.kikidan.domain.usecase.DateFortuneDefaults
+import com.kikidan.sajucontents.fake.FakeDayFortuneRepository
+import com.kikidan.sajucontents.model.DateFortuneSideEffect
+import com.kikidan.sajucontents.model.DateFortuneState
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toPersistentList
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.orbitmvi.orbit.test.test
+import java.time.LocalDate
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class DateFortuneViewModelTest {
+ private lateinit var fakeRepository: FakeDayFortuneRepository
+ private lateinit var createDayFortunes: CreateDayFortunesUseCase
+
+ @Before
+ fun setUp() {
+ // DateFortuneViewModel의 container(...)는 viewModelScope(Dispatchers.Main.immediate)를 쓴다.
+ Dispatchers.setMain(StandardTestDispatcher())
+ fakeRepository = FakeDayFortuneRepository()
+ createDayFortunes = CreateDayFortunesUseCase(fakeRepository)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun viewModel() = DateFortuneViewModel(createDayFortunes)
+
+ @Test
+ fun `목적_선택_시_selectedPurpose가_변경된다`() =
+ runTest {
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.selectPurpose(DayFortunePurpose.TRAVEL)
+ expectState { copy(selectedPurpose = DayFortunePurpose.TRAVEL) }
+ }
+ }
+
+ @Test
+ fun `날짜_토글로_추가와_제거가_selectedDates에_반영된다`() =
+ runTest {
+ val viewModel = viewModel()
+ val date = LocalDate.now()
+
+ viewModel.test(this) {
+ viewModel.toggleDate(date)
+ expectState { copy(selectedDates = persistentListOf(date)) }
+
+ viewModel.toggleDate(date)
+ expectState { copy(selectedDates = persistentListOf()) }
+ }
+ }
+
+ @Test
+ fun `5개_선택된_상태에서_추가_시도하면_상태는_불변이고_ShowToast가_발생한다`() =
+ runTest {
+ val viewModel = viewModel()
+ val today = LocalDate.now()
+ val fiveDates = (0 until DateFortuneDefaults.MAX_TARGET_DATES).map { today.plusDays(it.toLong()) }
+ val initial = DateFortuneState(selectedDates = fiveDates.toPersistentList())
+
+ viewModel.test(this, initialState = initial) {
+ viewModel.toggleDate(today.plusDays(10))
+ expectSideEffect(DateFortuneSideEffect.ShowToast)
+ }
+ }
+
+ @Test
+ fun `초기화하면_selectedDates가_비워진다`() =
+ runTest {
+ val viewModel = viewModel()
+ val initial =
+ DateFortuneState(
+ selectedPurpose = DayFortunePurpose.TRAVEL,
+ selectedDates = persistentListOf(LocalDate.now()),
+ )
+
+ viewModel.test(this, initialState = initial) {
+ viewModel.reset()
+ expectState { copy(selectedDates = persistentListOf()) }
+ }
+ }
+
+ @Test
+ fun `성별_선택_시_selectedGender가_변경된다`() =
+ runTest {
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.selectGender(Gender.FEMALE)
+ expectState { copy(selectedGender = Gender.FEMALE) }
+ }
+ }
+
+ @Test
+ fun `제출하면_UseCase에는_purpose와_targetDates_2개_인자만_전달되고_gender는_새지_않는다`() =
+ runTest {
+ val viewModel = viewModel()
+ val date = LocalDate.now()
+ val initial =
+ DateFortuneState(
+ selectedPurpose = DayFortunePurpose.TRAVEL,
+ selectedGender = Gender.MALE,
+ selectedDates = persistentListOf(date),
+ )
+ fakeRepository.result = Result.success(emptyList())
+
+ viewModel.test(this, initialState = initial) {
+ viewModel.submit()
+ expectState { copy(isLoading = true) }
+ expectState { copy(isLoading = false) }
+ expectSideEffect(DateFortuneSideEffect.NavigateToResult(emptyList()))
+ }
+
+ // CreateDayFortunesUseCase.invoke(purpose, targetDates) 시그니처 자체가 2-arity라 컴파일 타임에도
+ // gender가 섞일 수 없다. Fake Repository로 실제 전달값까지 한 번 더 고정한다 (B-2 회귀 방지).
+ assertEquals(DayFortunePurpose.TRAVEL, fakeRepository.lastPurpose)
+ assertEquals(listOf(date), fakeRepository.lastTargetDates)
+ }
+
+ @Test
+ fun `제출_성공_시_생성된_결과의_id_목록으로_NavigateToResult가_발생한다`() =
+ runTest {
+ val viewModel = viewModel()
+ val date = LocalDate.now()
+ val fortune =
+ DayFortune(
+ id = "id-1",
+ purpose = DayFortunePurpose.TRAVEL,
+ targetDate = date,
+ score = 85,
+ title = "title",
+ content = "content",
+ categoryStars = emptyList(),
+ )
+ fakeRepository.result = Result.success(listOf(fortune))
+ val initial =
+ DateFortuneState(
+ selectedPurpose = DayFortunePurpose.TRAVEL,
+ selectedDates = persistentListOf(date),
+ )
+
+ viewModel.test(this, initialState = initial) {
+ viewModel.submit()
+ expectState { copy(isLoading = true) }
+ expectState { copy(isLoading = false) }
+ expectSideEffect(DateFortuneSideEffect.NavigateToResult(listOf("id-1")))
+ }
+ }
+
+ @Test
+ fun `UseCase_실패_시_isLoading이_false로_돌아오고_ShowError가_발생한다`() =
+ runTest {
+ val viewModel = viewModel()
+ val date = LocalDate.now()
+ fakeRepository.result = Result.failure(IllegalStateException("서버 오류"))
+ val initial =
+ DateFortuneState(
+ selectedPurpose = DayFortunePurpose.TRAVEL,
+ selectedDates = persistentListOf(date),
+ )
+
+ viewModel.test(this, initialState = initial) {
+ viewModel.submit()
+ expectState { copy(isLoading = true) }
+ expectState { copy(isLoading = false) }
+ expectSideEffect(DateFortuneSideEffect.ShowError)
+ }
+ }
+}
diff --git a/feature/saju-contents/src/test/java/com/kikidan/sajucontents/YearFortuneResultViewModelTest.kt b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/YearFortuneResultViewModelTest.kt
new file mode 100644
index 00000000..61a7dc41
--- /dev/null
+++ b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/YearFortuneResultViewModelTest.kt
@@ -0,0 +1,130 @@
+package com.kikidan.sajucontents
+
+import com.kikidan.domain.model.fortune.FortuneCategory
+import com.kikidan.domain.model.fortune.FortuneCategoryStar
+import com.kikidan.domain.model.fortune.YearFortune
+import com.kikidan.domain.usecase.GetYearFortuneUseCase
+import com.kikidan.sajucontents.fake.FakeYearFortuneRepository
+import com.kikidan.sajucontents.model.YearFortuneResultSideEffect
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.orbitmvi.orbit.test.test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class YearFortuneResultViewModelTest {
+ private lateinit var fakeRepository: FakeYearFortuneRepository
+ private lateinit var getYearFortune: GetYearFortuneUseCase
+
+ @Before
+ fun setUp() {
+ // YearFortuneResultViewModel의 container(...)는 viewModelScope(Dispatchers.Main.immediate)를 쓴다.
+ Dispatchers.setMain(StandardTestDispatcher())
+ fakeRepository = FakeYearFortuneRepository()
+ getYearFortune = GetYearFortuneUseCase(fakeRepository)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun viewModel() = YearFortuneResultViewModel(getYearFortune)
+
+ @Test
+ fun `load_성공_시_fortuneResult가_설정된다`() =
+ runTest {
+ val viewModel = viewModel()
+ val fortune =
+ YearFortune(
+ id = "id-1",
+ year = 2026,
+ score = 85,
+ title = "title",
+ content = "content",
+ categories = listOf(FortuneCategoryStar(FortuneCategory.MONEY, 3)),
+ )
+ fakeRepository.result = Result.success(fortune)
+
+ viewModel.test(this) {
+ viewModel.load("id-1")
+ expectState { copy(isLoading = true) }
+ expectState { copy(isLoading = false, fortuneResult = fortune) }
+ }
+
+ assertEquals("id-1", fakeRepository.lastId)
+ }
+
+ @Test
+ fun `load_실패_시_isLoading이_false로_돌아오고_ShowError가_발생한다`() =
+ runTest {
+ val viewModel = viewModel()
+ fakeRepository.result = Result.failure(IllegalStateException("서버 오류"))
+
+ viewModel.test(this) {
+ viewModel.load("id-1")
+ expectState { copy(isLoading = true) }
+ expectState { copy(isLoading = false) }
+ expectSideEffect(YearFortuneResultSideEffect.ShowError)
+ }
+ }
+
+ @Test
+ fun `공유_아이콘_탭_시_isShareDialogVisible이_true가_된다`() =
+ runTest {
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.showShareDialog()
+ expectState { copy(isShareDialogVisible = true) }
+ }
+ }
+
+ @Test
+ fun `취소_시_isShareDialogVisible이_false가_된다`() =
+ runTest {
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.showShareDialog()
+ expectState { copy(isShareDialogVisible = true) }
+ viewModel.hideShareDialog()
+ expectState { copy(isShareDialogVisible = false) }
+ }
+ }
+
+ @Test
+ fun `카카오_공유_실패_시_팝업이_닫히고_ShowShareError가_발생한다`() =
+ runTest {
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.showShareDialog()
+ expectState { copy(isShareDialogVisible = true) }
+ viewModel.notifyShareUnavailable()
+ expectState { copy(isShareDialogVisible = false) }
+ expectSideEffect(YearFortuneResultSideEffect.ShowShareError)
+ }
+ }
+
+ @Test
+ fun `URL_복사_시_팝업이_닫히고_ShowUrlCopied가_발생한다`() =
+ runTest {
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.showShareDialog()
+ expectState { copy(isShareDialogVisible = true) }
+ viewModel.notifyUrlCopied()
+ expectState { copy(isShareDialogVisible = false) }
+ expectSideEffect(YearFortuneResultSideEffect.ShowUrlCopied)
+ }
+ }
+}
diff --git a/feature/saju-contents/src/test/java/com/kikidan/sajucontents/YearFortuneViewModelTest.kt b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/YearFortuneViewModelTest.kt
new file mode 100644
index 00000000..481a8f68
--- /dev/null
+++ b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/YearFortuneViewModelTest.kt
@@ -0,0 +1,93 @@
+package com.kikidan.sajucontents
+
+import com.kikidan.domain.model.fortune.FortuneCategory
+import com.kikidan.domain.model.fortune.FortuneCategoryStar
+import com.kikidan.domain.model.fortune.YearFortune
+import com.kikidan.domain.usecase.CreateYearFortuneUseCase
+import com.kikidan.sajucontents.fake.FakeYearFortuneRepository
+import com.kikidan.sajucontents.model.YearFortuneSideEffect
+import com.kikidan.sajucontents.model.YearFortuneState
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.orbitmvi.orbit.test.test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class YearFortuneViewModelTest {
+ private lateinit var fakeRepository: FakeYearFortuneRepository
+ private lateinit var createYearFortune: CreateYearFortuneUseCase
+
+ @Before
+ fun setUp() {
+ // YearFortuneViewModel의 container(...)는 viewModelScope(Dispatchers.Main.immediate)를 쓴다.
+ Dispatchers.setMain(StandardTestDispatcher())
+ fakeRepository = FakeYearFortuneRepository()
+ createYearFortune = CreateYearFortuneUseCase(fakeRepository)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun viewModel() = YearFortuneViewModel(createYearFortune)
+
+ @Test
+ fun `연도_선택_시_selectedYear가_변경된다`() =
+ runTest {
+ val viewModel = viewModel()
+
+ viewModel.test(this) {
+ viewModel.onYearSelect(2030)
+ expectState { copy(selectedYear = 2030) }
+ }
+ }
+
+ @Test
+ fun `제출_성공_시_생성된_결과의_id로_NavigateToResult가_발생한다`() =
+ runTest {
+ val viewModel = viewModel()
+ val fortune =
+ YearFortune(
+ id = "id-1",
+ year = 2026,
+ score = 85,
+ title = "title",
+ content = "content",
+ categories = listOf(FortuneCategoryStar(FortuneCategory.MONEY, 3)),
+ )
+ fakeRepository.result = Result.success(fortune)
+ val initial = YearFortuneState(selectedYear = 2026)
+
+ viewModel.test(this, initialState = initial) {
+ viewModel.onSubmit()
+ expectState { copy(isLoading = true) }
+ expectState { copy(isLoading = false) }
+ expectSideEffect(YearFortuneSideEffect.NavigateToResult("id-1"))
+ }
+
+ assertEquals(2026, fakeRepository.lastYear)
+ }
+
+ @Test
+ fun `UseCase_실패_시_isLoading이_false로_돌아오고_ShowError가_발생한다`() =
+ runTest {
+ val viewModel = viewModel()
+ fakeRepository.result = Result.failure(IllegalStateException("서버 오류"))
+ val initial = YearFortuneState(selectedYear = 2026)
+
+ viewModel.test(this, initialState = initial) {
+ viewModel.onSubmit()
+ expectState { copy(isLoading = true) }
+ expectState { copy(isLoading = false) }
+ expectSideEffect(YearFortuneSideEffect.ShowError)
+ }
+ }
+}
diff --git a/feature/saju-contents/src/test/java/com/kikidan/sajucontents/component/CalendarDayStateTest.kt b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/component/CalendarDayStateTest.kt
new file mode 100644
index 00000000..c4bbb001
--- /dev/null
+++ b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/component/CalendarDayStateTest.kt
@@ -0,0 +1,53 @@
+package com.kikidan.sajucontents.component
+
+import kotlinx.collections.immutable.persistentListOf
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.time.LocalDate
+
+class CalendarDayStateTest {
+ private val today = LocalDate.of(2026, 8, 10)
+
+ @Test
+ fun `과거_날짜는_PAST로_판정된다`() {
+ val pastDate = today.minusDays(1)
+
+ val state = resolveCalendarDayState(pastDate, today, persistentListOf())
+
+ assertEquals(CalendarDayState.PAST, state)
+ }
+
+ @Test
+ fun `오늘_날짜는_선택되지_않았으면_TODAY로_판정된다`() {
+ val state = resolveCalendarDayState(today, today, persistentListOf())
+
+ assertEquals(CalendarDayState.TODAY, state)
+ }
+
+ @Test
+ fun `미래_날짜이면서_미선택이면_DEFAULT로_판정된다`() {
+ val futureDate = today.plusDays(3)
+
+ val state = resolveCalendarDayState(futureDate, today, persistentListOf())
+
+ assertEquals(CalendarDayState.DEFAULT, state)
+ }
+
+ @Test
+ fun `미래_날짜이면서_선택되었으면_SELECTED로_판정된다`() {
+ val futureDate = today.plusDays(3)
+
+ val state = resolveCalendarDayState(futureDate, today, persistentListOf(futureDate))
+
+ assertEquals(CalendarDayState.SELECTED, state)
+ }
+
+ @Test
+ fun `과거_날짜는_선택_목록에_있어도_PAST가_우선한다`() {
+ val pastDate = today.minusDays(1)
+
+ val state = resolveCalendarDayState(pastDate, today, persistentListOf(pastDate))
+
+ assertEquals(CalendarDayState.PAST, state)
+ }
+}
diff --git a/feature/saju-contents/src/test/java/com/kikidan/sajucontents/fake/FakeCompatibilityRepository.kt b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/fake/FakeCompatibilityRepository.kt
new file mode 100644
index 00000000..abfca407
--- /dev/null
+++ b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/fake/FakeCompatibilityRepository.kt
@@ -0,0 +1,40 @@
+package com.kikidan.sajucontents.fake
+
+import com.kikidan.domain.model.compatibility.Compatibility
+import com.kikidan.domain.model.compatibility.CompatibilityOhaeng
+import com.kikidan.domain.model.saju.Ohaeng
+import com.kikidan.domain.model.saju.RelationshipType
+import com.kikidan.domain.repository.CompatibilityRepository
+
+/**
+ * core/domain에는 test-fixtures가 구성되어 있지 않아 동일한 Fake를 모듈별로 둔다.
+ */
+class FakeCompatibilityRepository : CompatibilityRepository {
+ var result: Result =
+ Result.success(
+ Compatibility(
+ id = "compat-1",
+ partnerName = "토실이",
+ relationshipType = RelationshipType("LOVER", "연인"),
+ score = 85,
+ headline = "함께할 수록 빛나는 궁합",
+ subheadline = "함께 있을 때, 편안함이 커지는 사이예요.",
+ summary = "요약",
+ totalAnalysis = "총운 분석",
+ analysisBasis = "사주 팔자 기반",
+ ohaengs = listOf(CompatibilityOhaeng(Ohaeng.MOK, 25)),
+ ),
+ )
+ var lastPartnerLinkId: String? = null
+ var lastCompatibilityId: String? = null
+
+ override suspend fun createCompatibility(partnerLinkId: String): Result {
+ lastPartnerLinkId = partnerLinkId
+ return result
+ }
+
+ override suspend fun getCompatibility(compatibilityId: String): Result {
+ lastCompatibilityId = compatibilityId
+ return result
+ }
+}
diff --git a/feature/saju-contents/src/test/java/com/kikidan/sajucontents/fake/FakeDayFortuneRepository.kt b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/fake/FakeDayFortuneRepository.kt
new file mode 100644
index 00000000..b7e0877a
--- /dev/null
+++ b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/fake/FakeDayFortuneRepository.kt
@@ -0,0 +1,35 @@
+package com.kikidan.sajucontents.fake
+
+import com.kikidan.domain.model.dayfortune.DayFortune
+import com.kikidan.domain.model.dayfortune.DayFortunePurpose
+import com.kikidan.domain.repository.DayFortuneRepository
+import java.time.LocalDate
+
+/**
+ * core/domain에는 test-fixtures가 구성되어 있지 않아 동일한 Fake를 모듈별로 둔다.
+ * `CreateDayFortunesUseCase`가 final class라 이 Fake로 실제 UseCase를 조립해 사용한다.
+ */
+class FakeDayFortuneRepository : DayFortuneRepository {
+ var result: Result> = Result.success(emptyList())
+ var callCount: Int = 0
+ private set
+ var lastPurpose: DayFortunePurpose? = null
+ private set
+ var lastTargetDates: List? = null
+ private set
+
+ var getResults: MutableMap> = mutableMapOf()
+
+ override suspend fun createDayFortunes(
+ purpose: DayFortunePurpose,
+ targetDates: List,
+ ): Result> {
+ callCount++
+ lastPurpose = purpose
+ lastTargetDates = targetDates
+ return result
+ }
+
+ override suspend fun getDayFortune(id: String): Result =
+ getResults[id] ?: Result.failure(IllegalStateException("$id 에 대한 결과가 설정되지 않았습니다."))
+}
diff --git a/feature/saju-contents/src/test/java/com/kikidan/sajucontents/fake/FakePartnerSajuRepository.kt b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/fake/FakePartnerSajuRepository.kt
new file mode 100644
index 00000000..43e33acc
--- /dev/null
+++ b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/fake/FakePartnerSajuRepository.kt
@@ -0,0 +1,54 @@
+package com.kikidan.sajucontents.fake
+
+import com.kikidan.domain.model.saju.CheonGan
+import com.kikidan.domain.model.saju.PartnerSaju
+import com.kikidan.domain.model.saju.PartnerSajuInput
+import com.kikidan.domain.model.saju.RelationshipType
+import com.kikidan.domain.model.saju.SajuChartDetail
+import com.kikidan.domain.model.user.Birth
+import com.kikidan.domain.model.user.BirthTime
+import com.kikidan.domain.model.user.DateType
+import com.kikidan.domain.model.user.Gender
+import com.kikidan.domain.repository.PartnerSajuRepository
+import java.time.LocalDate
+
+/**
+ * core/domain에는 test-fixtures가 구성되어 있지 않아 동일한 Fake를 모듈별로 둔다.
+ */
+class FakePartnerSajuRepository : PartnerSajuRepository {
+ var listResult: Result> =
+ Result.success(
+ listOf(
+ PartnerSaju(
+ linkId = "partner-1",
+ name = "토실이",
+ gender = Gender.MALE,
+ relationshipType = RelationshipType("LOVER", "연인"),
+ birth = Birth(DateType.SOLAR, LocalDate.of(2001, 5, 30), BirthTime.O),
+ ),
+ ),
+ )
+ var getResult: Result = listResult.map { it.first() }
+ var chartDetailResult: Result =
+ Result.success(SajuChartDetail(dayMaster = CheonGan.GI, pillars = emptyList(), fiveElements = emptyList()))
+ var registerResult: Result = Result.success(Unit)
+ var lastRegisterInput: PartnerSajuInput? = null
+
+ override suspend fun getPartnerSajuList(): Result> = listResult
+
+ override suspend fun getPartnerSaju(linkId: String): Result = getResult
+
+ override suspend fun getPartnerSajuChartDetail(linkId: String): Result = chartDetailResult
+
+ override suspend fun deletePartnerSaju(linkId: String): Result = Result.success(Unit)
+
+ override suspend fun registerPartnerSaju(input: PartnerSajuInput): Result {
+ lastRegisterInput = input
+ return registerResult
+ }
+
+ override suspend fun updatePartnerSaju(
+ linkId: String,
+ input: PartnerSajuInput,
+ ): Result = Result.success(Unit)
+}
diff --git a/feature/saju-contents/src/test/java/com/kikidan/sajucontents/fake/FakeSajuRepository.kt b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/fake/FakeSajuRepository.kt
new file mode 100644
index 00000000..3d8d629c
--- /dev/null
+++ b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/fake/FakeSajuRepository.kt
@@ -0,0 +1,30 @@
+package com.kikidan.sajucontents.fake
+
+import com.kikidan.domain.model.saju.CheonGan
+import com.kikidan.domain.model.saju.JiJi
+import com.kikidan.domain.model.saju.SajuChartDetail
+import com.kikidan.domain.model.saju.SajuPalja
+import com.kikidan.domain.model.saju.SajuPillar
+import com.kikidan.domain.repository.SajuRepository
+
+/**
+ * core/domain에는 test-fixtures가 구성되어 있지 않아 동일한 Fake를 모듈별로 둔다.
+ */
+class FakeSajuRepository : SajuRepository {
+ var paljaResult: Result =
+ Result.success(
+ SajuPalja(
+ yearPillar = SajuPillar(CheonGan.JEONG, JiJi.CHUK),
+ monthPillar = SajuPillar(CheonGan.GYE, JiJi.MYO),
+ dayPillar = SajuPillar(CheonGan.GI, JiJi.SA),
+ hourPillar = SajuPillar(CheonGan.SIN, JiJi.MI),
+ ),
+ )
+
+ var chartDetailResult: Result =
+ Result.success(SajuChartDetail(dayMaster = CheonGan.GI, pillars = emptyList(), fiveElements = emptyList()))
+
+ override suspend fun getSajuPalja(): Result = paljaResult
+
+ override suspend fun getMySajuChartDetail(): Result = chartDetailResult
+}
diff --git a/feature/saju-contents/src/test/java/com/kikidan/sajucontents/fake/FakeUserRepository.kt b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/fake/FakeUserRepository.kt
new file mode 100644
index 00000000..4ff56c23
--- /dev/null
+++ b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/fake/FakeUserRepository.kt
@@ -0,0 +1,40 @@
+package com.kikidan.sajucontents.fake
+
+import com.kikidan.domain.model.user.Birth
+import com.kikidan.domain.model.user.BirthTime
+import com.kikidan.domain.model.user.DateType
+import com.kikidan.domain.model.user.Gender
+import com.kikidan.domain.model.user.Job
+import com.kikidan.domain.model.user.RelationshipStatus
+import com.kikidan.domain.model.user.User
+import com.kikidan.domain.model.user.WithdrawalReason
+import com.kikidan.domain.repository.UserRepository
+import java.time.LocalDate
+
+/**
+ * core/domain에는 test-fixtures가 구성되어 있지 않아 동일한 Fake를 모듈별로 둔다.
+ */
+class FakeUserRepository : UserRepository {
+ var result: Result =
+ Result.success(
+ User(
+ id = "fake-id",
+ name = "토닥이",
+ gender = Gender.FEMALE,
+ job = Job.WORKER,
+ relationshipStatus = RelationshipStatus.SOLO,
+ birth = Birth(DateType.SOLAR, LocalDate.of(2001, 5, 30), BirthTime.O),
+ ),
+ )
+
+ override suspend fun getUserInfo(): Result = result
+
+ override suspend fun updateUserInfo(user: User): Result = Result.success(Unit)
+
+ override suspend fun withdraw(
+ reason: WithdrawalReason,
+ detail: String?,
+ ): Result {
+ TODO("Not yet implemented")
+ }
+}
diff --git a/feature/saju-contents/src/test/java/com/kikidan/sajucontents/fake/FakeYearFortuneRepository.kt b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/fake/FakeYearFortuneRepository.kt
new file mode 100644
index 00000000..212a5f56
--- /dev/null
+++ b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/fake/FakeYearFortuneRepository.kt
@@ -0,0 +1,36 @@
+package com.kikidan.sajucontents.fake
+
+import com.kikidan.domain.model.fortune.YearFortune
+import com.kikidan.domain.repository.YearFortuneRepository
+
+/**
+ * core/domain에는 test-fixtures가 구성되어 있지 않아 동일한 Fake를 모듈별로 둔다.
+ * `GetYearFortuneUseCase`가 final class라 이 Fake로 실제 UseCase를 조립해 사용한다.
+ */
+class FakeYearFortuneRepository : YearFortuneRepository {
+ var result: Result =
+ Result.success(
+ YearFortune(
+ id = "fake-id",
+ year = 2026,
+ score = 0,
+ title = "",
+ content = "",
+ categories = emptyList(),
+ ),
+ )
+ var lastYear: Int? = null
+ private set
+ var lastId: String? = null
+ private set
+
+ override suspend fun createYearFortune(year: Int): Result {
+ lastYear = year
+ return result
+ }
+
+ override suspend fun getYearFortune(id: String): Result {
+ lastId = id
+ return result
+ }
+}
diff --git a/feature/saju-contents/src/test/java/com/kikidan/sajucontents/model/DateFortuneStateTest.kt b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/model/DateFortuneStateTest.kt
new file mode 100644
index 00000000..060f8240
--- /dev/null
+++ b/feature/saju-contents/src/test/java/com/kikidan/sajucontents/model/DateFortuneStateTest.kt
@@ -0,0 +1,62 @@
+package com.kikidan.sajucontents.model
+
+import com.kikidan.domain.model.dayfortune.DayFortunePurpose
+import com.kikidan.domain.model.user.Gender
+import kotlinx.collections.immutable.persistentListOf
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.time.LocalDate
+
+class DateFortuneStateTest {
+ @Test
+ fun `목적이_없으면_canSubmit은_false다`() {
+ val state = DateFortuneState(selectedPurpose = null, selectedDates = persistentListOf(LocalDate.now()))
+
+ assertFalse(state.canSubmit)
+ }
+
+ @Test
+ fun `날짜가_0개면_canSubmit은_false다`() {
+ val state = DateFortuneState(selectedPurpose = DayFortunePurpose.TRAVEL, selectedDates = persistentListOf())
+
+ assertFalse(state.canSubmit)
+ }
+
+ @Test
+ fun `제출_중이면_canSubmit은_false다`() {
+ val state =
+ DateFortuneState(
+ selectedPurpose = DayFortunePurpose.TRAVEL,
+ selectedDates = persistentListOf(LocalDate.now()),
+ isLoading = true,
+ )
+
+ assertFalse(state.canSubmit)
+ }
+
+ @Test
+ fun `목적과_날짜가_있으면_성별_미선택이어도_canSubmit은_true다`() {
+ // 서버로 보내지 않는 값이라 필수 조건에서 제외한다 (블로커 B-2 회귀 방지).
+ val state =
+ DateFortuneState(
+ selectedPurpose = DayFortunePurpose.TRAVEL,
+ selectedGender = null,
+ selectedDates = persistentListOf(LocalDate.now()),
+ )
+
+ assertTrue(state.canSubmit)
+ }
+
+ @Test
+ fun `목적_날짜_성별이_모두_있으면_canSubmit은_true다`() {
+ val state =
+ DateFortuneState(
+ selectedPurpose = DayFortunePurpose.TRAVEL,
+ selectedGender = Gender.MALE,
+ selectedDates = persistentListOf(LocalDate.now()),
+ )
+
+ assertTrue(state.canSubmit)
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 711bf99f..9e7eb299 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -30,6 +30,7 @@ kakao = "2.24.0"
androidx-credentials = "1.6.0"
googleid = "1.2.0"
kotlinx-collections-immutable = "0.5.1"
+kizitonwoseCalendar = "2.10.1"
material3 = "1.4.0"
kyantBackdrop = "1.0.2"
haze = "1.7.2"
@@ -89,6 +90,7 @@ androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecy
androidx-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "material3AdaptiveNav3" }
nlopez-compose-rules-ktlint = { group = "io.nlopez.compose.rules", name = "ktlint", version.ref = "composeRules" }
kakao-user = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao" }
+kakao-share = { group = "com.kakao.sdk", name = "v2-share", version.ref = "kakao" }
androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "androidx-credentials" }
androidx-credentials-play-services-auth = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "androidx-credentials" }
googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleid" }
@@ -97,6 +99,7 @@ ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref =
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
kyant-backdrop = { group = "io.github.kyant0", name = "backdrop", version.ref = "kyantBackdrop" }
+kizitonwose-calendar-compose = { group = "com.kizitonwose.calendar", name = "compose", version.ref = "kizitonwoseCalendar" }
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
firebase-messaging = { module = "com.google.firebase:firebase-messaging" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 6672945e..3575faa4 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -38,6 +38,7 @@ include(":core:navigation")
include(":feature:onboarding")
include(":core:designsystem")
include(":feature:auth")
+include(":feature:saju-contents")
include(":feature:luck-action")
include(":feature:home")
include(":feature:chat")