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")