diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index ba29fd0c..866d40de 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -45,9 +45,6 @@
-
-
-
@@ -59,4 +56,4 @@
-
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index aa214dd2..2cae24bd 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -20,4 +20,4 @@
-
+
\ No newline at end of file
diff --git a/app/src/main/java/kr/co/call/callfromai/AppScreen.kt b/app/src/main/java/kr/co/call/callfromai/AppScreen.kt
index 580eeb38..a8424039 100644
--- a/app/src/main/java/kr/co/call/callfromai/AppScreen.kt
+++ b/app/src/main/java/kr/co/call/callfromai/AppScreen.kt
@@ -1,8 +1,14 @@
package kr.co.call.callfromai
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
@@ -68,13 +74,30 @@ fun AppScreen(modifier: Modifier = Modifier) {
with(density) { bottomBarHeightPx.toDp() }
}
- Box(modifier = modifier.fillMaxSize()) {
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ bottomBar = {
+ if (showBottomBar) {
+ MainBottomBar(
+ currentTab = currentTab,
+ onTabSelected = appNavigator::navigateToTab,
+ modifier = Modifier
+ .onSizeChanged { bottomBarHeightPx = it.height },
+ )
+ }
+ },
+ contentWindowInsets = WindowInsets.safeDrawing.only(
+ WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal
+ ),
+
+ ) {
+ padding ->
CompositionLocalProvider(
LocalBottomBarPadding provides if (showBottomBar) bottomBarPadding else 0.dp,
) {
NavDisplay(
backStack = backStack,
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier.fillMaxSize().padding(padding),
entryProvider = entryProvider {
loginEntry()
onboardingEntry()
@@ -84,14 +107,5 @@ fun AppScreen(modifier: Modifier = Modifier) {
}
)
}
- if (showBottomBar) {
- MainBottomBar(
- currentTab = currentTab,
- onTabSelected = appNavigator::navigateToTab,
- modifier = Modifier
- .align(Alignment.BottomCenter)
- .onSizeChanged { bottomBarHeightPx = it.height },
- )
- }
}
}
diff --git a/core/common/src/main/java/kr/co/call/core/common/util/TimeUtil.kt b/core/common/src/main/java/kr/co/call/core/common/util/TimeUtil.kt
new file mode 100644
index 00000000..8c1fa092
--- /dev/null
+++ b/core/common/src/main/java/kr/co/call/core/common/util/TimeUtil.kt
@@ -0,0 +1,87 @@
+package kr.co.call.core.common.util
+
+import java.time.Duration
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+
+/**
+ * 날짜 및 시간과 관련된 변환 및 포맷팅 처리를 담당하는 유틸리티 객체입니다.
+ *
+ * 주로 한국어 로케일을 기반으로 통화 내역, 홈 화면, 예약 시간 등에 사용되는
+ * 다양한 형식의 문자열 변환 기능을 제공합니다.
+ */
+object TimeUtil {
+
+ // LocalDateTime을 String으로 변환
+ fun parseLocalDateTime(value: String): LocalDateTime =
+ LocalDateTime.parse(value)
+
+ // LocalDate를 String으로 변환
+ fun toCallHistoryDateText(dateTime: LocalDateTime): String =
+ dateTime.format(
+ DateTimeFormatter.ofPattern(
+ "M월 d일 a h시 m분",
+ Locale.KOREAN,
+ ),
+ )
+
+ // LocalDateTime을 "n분 전" 형식의 문자열로 변환
+ fun toTimeAgoText(
+ dateTime: LocalDateTime,
+ now: LocalDateTime = LocalDateTime.now(),
+ ): String {
+ val elapsedMinutes = Duration.between(dateTime, now)
+ .toMinutes()
+ .coerceAtLeast(0)
+
+ return when {
+ elapsedMinutes < 60 -> "${elapsedMinutes}분 전"
+ elapsedMinutes < 60 * 24 -> "${elapsedMinutes / 60}시간 전"
+ else -> "${elapsedMinutes / (60 * 24)}일 전"
+ }
+ }
+
+ // LocalDate를 "M월 d일 EEEE" 형식의 문자열로 변환
+ fun toHomeDateText(date: LocalDate): String =
+ date.format(
+ DateTimeFormatter.ofPattern(
+ "M월 d일 EEEE",
+ Locale.KOREAN,
+ ),
+ )
+
+ // LocalDateTime을 "오늘/내일 + 시간" 형식의 문자열로 변환
+ fun toReservationTimeText(
+ dateTime: LocalDateTime,
+ today: LocalDate = LocalDate.now(),
+ ): String {
+ val dateText = when (dateTime.toLocalDate()) {
+ today -> "오늘"
+ today.plusDays(1) -> "내일"
+ else -> dateTime.format(DateTimeFormatter.ofPattern("M월 d일"))
+ }
+ val periodText = when (dateTime.hour) {
+ in 0..5 -> "새벽"
+ in 6..11 -> "아침"
+ in 12..17 -> "낮"
+ in 18..20 -> "저녁"
+ else -> "밤"
+ }
+ val hourText = when (val hourOf12 = dateTime.hour % 12) {
+ 0 -> 12
+ else -> hourOf12
+ }
+ val minuteText = if (dateTime.minute == 0) {
+ ""
+ } else {
+ " ${dateTime.minute}분"
+ }
+
+ return "$dateText $periodText ${hourText}시$minuteText"
+ }
+
+ fun toHourMinuteText(dateTime: LocalDateTime): String =
+ dateTime.format(DateTimeFormatter.ofPattern("HH:mm"))
+}
diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts
index bd0a7d54..7be78824 100644
--- a/core/data/build.gradle.kts
+++ b/core/data/build.gradle.kts
@@ -10,6 +10,7 @@ android {
dependencies {
// 다른 core 모듈 의존성
+ implementation(project(":core:common"))
implementation(project(":core:domain"))
implementation(project(":core:network"))
implementation(project(":core:datastore"))
@@ -20,4 +21,4 @@ dependencies {
// timber
implementation(libs.timber)
-}
\ No newline at end of file
+}
diff --git a/core/data/src/main/java/kr/co/call/data/di/RepositoryModule.kt b/core/data/src/main/java/kr/co/call/data/di/RepositoryModule.kt
index ec46ea19..31c8e7cc 100644
--- a/core/data/src/main/java/kr/co/call/data/di/RepositoryModule.kt
+++ b/core/data/src/main/java/kr/co/call/data/di/RepositoryModule.kt
@@ -1,9 +1,12 @@
package kr.co.call.data.di
-import dagger.Binds
import dagger.Module
+import dagger.Binds
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+import kr.co.call.data.repositoryImpl.HomeRepositoryImpl
+import kr.co.call.domain.repository.HomeRepository
import kr.co.call.data.repositoryImpl.MyPageRepositoryImpl
import kr.co.call.domain.repository.MyPageRepository
@@ -11,6 +14,11 @@ import kr.co.call.domain.repository.MyPageRepository
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
+ @Binds
+ @Singleton
+ abstract fun bindHomeRepository(
+ homeRepositoryImpl: HomeRepositoryImpl,
+ ): HomeRepository
@Binds
abstract fun bindMyPageRepository(
impl: MyPageRepositoryImpl
diff --git a/core/data/src/main/java/kr/co/call/data/di/UseCaseModule.kt b/core/data/src/main/java/kr/co/call/data/di/UseCaseModule.kt
index 39d7751a..14ac7084 100644
--- a/core/data/src/main/java/kr/co/call/data/di/UseCaseModule.kt
+++ b/core/data/src/main/java/kr/co/call/data/di/UseCaseModule.kt
@@ -7,5 +7,4 @@ import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
object UseCaseModule {
-
-}
\ No newline at end of file
+}
diff --git a/core/data/src/main/java/kr/co/call/data/mapper/CallHistoryMapper.kt b/core/data/src/main/java/kr/co/call/data/mapper/CallHistoryMapper.kt
new file mode 100644
index 00000000..4898d41d
--- /dev/null
+++ b/core/data/src/main/java/kr/co/call/data/mapper/CallHistoryMapper.kt
@@ -0,0 +1,62 @@
+package kr.co.call.data.mapper
+
+import java.time.LocalDateTime
+import kr.co.call.core.common.util.TimeUtil
+import kr.co.call.domain.model.home.CallHistory
+import kr.co.call.domain.model.home.CallReservation
+import kr.co.call.domain.model.home.CallReservations
+import kr.co.call.domain.model.home.HomeSummary
+import kr.co.call.network.dto.CallHistoryDto
+import kr.co.call.network.dto.HomeSummaryDto
+import kr.co.call.network.dto.ReservationListDto
+import kr.co.call.network.dto.ReservationDto
+
+internal fun CallHistoryDto.toDomain(): CallHistory {
+ val parsedStartedAt = TimeUtil.parseLocalDateTime(startedAt)
+
+ return CallHistory(
+ callId = callId,
+ characterName = characterName,
+ aiSummary = aiSummary,
+ startedAt = parsedStartedAt,
+ isOutgoing = sender.toIsOutgoing(),
+ isMissed = status.toIsMissed(),
+ )
+}
+
+private fun String.toIsOutgoing(): Boolean =
+ when (this) {
+ "USER" -> true
+ "AI" -> false
+ else -> error("지원하지 않는 통화 발신자입니다: $this")
+ }
+
+private fun String.toIsMissed(): Boolean =
+ when (this) {
+ "COMPLETED" -> false
+ "MISSED" -> true
+ else -> error("지원하지 않는 통화 상태입니다: $this")
+ }
+
+internal fun ReservationListDto.toDomain(): CallReservations =
+ CallReservations(
+ totalCount = count,
+ items = content.map { it.toDomain() },
+ )
+
+private fun ReservationDto.toDomain(): CallReservation =
+ CallReservation(
+ id = callReservationId,
+ characterId = characterId,
+ firstName = firstName,
+ imageUrl = imageUrl,
+ scheduledAt = LocalDateTime.parse(scheduledAt),
+ )
+
+internal fun HomeSummaryDto.toDomain(): HomeSummary =
+ HomeSummary(
+ firstName = firstName,
+ relationshipDays = relationshipDays,
+ totalCallCount = totalCallCount,
+ callStreakDays = callStreakDays,
+ )
diff --git a/core/data/src/main/java/kr/co/call/data/repositoryImpl/HomeRepositoryImpl.kt b/core/data/src/main/java/kr/co/call/data/repositoryImpl/HomeRepositoryImpl.kt
new file mode 100644
index 00000000..657cf539
--- /dev/null
+++ b/core/data/src/main/java/kr/co/call/data/repositoryImpl/HomeRepositoryImpl.kt
@@ -0,0 +1,85 @@
+package kr.co.call.data.repositoryImpl
+
+import java.time.LocalDateTime
+import javax.inject.Inject
+import kr.co.call.domain.model.home.CallHistory
+import kr.co.call.domain.model.home.CallReservation
+import kr.co.call.domain.model.home.CallReservations
+import kr.co.call.domain.model.home.HomeSummary
+import kr.co.call.domain.repository.HomeRepository
+
+class HomeRepositoryImpl @Inject constructor() : HomeRepository {
+
+ override suspend fun getReservations(): Result =
+ Result.success(
+ CallReservations(
+ totalCount = 1,
+ items = listOf(
+ CallReservation(
+ id = 3L,
+ characterId = 2L,
+ firstName = "민준",
+ imageUrl = null,
+ scheduledAt = LocalDateTime.of(2026, 6, 30, 21, 0),
+ ),
+ ),
+ ),
+ )
+
+ override suspend fun getCallHistories(): Result> =
+ Result.success(
+ List(CALL_HISTORY_SIZE) { index ->
+ CallHistory(
+ callId = (index + 1).toLong(),
+ characterName = if (index % 2 == 0) "민준" else "동휘",
+ aiSummary = if (index % 2 == 0) {
+ "오늘 하루와 퇴근 후 일상 이야기"
+ } else {
+ "몸살 감기 기운과 걱정해주는 이야기"
+ },
+ startedAt = LocalDateTime.of(
+ 2026,
+ 6,
+ (28 - index).coerceAtLeast(1),
+ 23,
+ 2,
+ ),
+ isOutgoing = index % 2 == 0,
+ isMissed = index % 4 == 3,
+ )
+ },
+ )
+
+ override suspend fun getSummary(): Result =
+ Result.success(
+ HomeSummary(
+ firstName = "수현",
+ relationshipDays = 30,
+ totalCallCount = 24,
+ callStreakDays = 12,
+ ),
+ )
+
+ override suspend fun changeReservationTime(
+ reservationId: Long,
+ scheduledAt: LocalDateTime,
+ ): Result =
+ Result.success(
+ CallReservations(
+ totalCount = 1,
+ items = listOf(
+ CallReservation(
+ id = reservationId,
+ characterId = 2L,
+ firstName = "민준",
+ imageUrl = null,
+ scheduledAt = scheduledAt,
+ ),
+ ),
+ ),
+ )
+
+ private companion object {
+ const val CALL_HISTORY_SIZE = 20
+ }
+}
diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts
index b1f5d143..df0969c1 100644
--- a/core/designsystem/build.gradle.kts
+++ b/core/designsystem/build.gradle.kts
@@ -10,6 +10,9 @@ android {
dependencies {
// Compose 의존성은 AndroidComposeConventionPlugin에서 자동 추가됨
+ // coil
+ implementation(libs.coil.compose)
+
// android 의존성
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.compose.material3)
diff --git a/core/designsystem/src/main/java/kr/co/call/designsystem/component/ProfileImage.kt b/core/designsystem/src/main/java/kr/co/call/designsystem/component/ProfileImage.kt
new file mode 100644
index 00000000..a7ffabdf
--- /dev/null
+++ b/core/designsystem/src/main/java/kr/co/call/designsystem/component/ProfileImage.kt
@@ -0,0 +1,79 @@
+package kr.co.call.designsystem.component
+
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import coil.compose.AsyncImage
+import kr.co.call.designsystem.theme.CallFromAiTheme
+
+/**
+ * 프로필 이미지를 불러오는 컴포넌트
+ * - 공통 재사용 가능
+ * - URL이 없거나 로딩에 실패하면 기본 이미지 표시
+ * - 통화 배지 표시 여부 설정 가능
+ */
+@Composable
+fun ProfileImage(
+ profileImageUrl: String?,
+ modifier: Modifier = Modifier,
+ showCallBadge: Boolean = false,
+) {
+ val defaultProfile = painterResource(
+ id = kr.co.call.designsystem.R.drawable.img_profile_url_default,
+ )
+
+ Box(
+ modifier = modifier.size(
+ width = 49.dp,
+ height = 51.dp,
+ ),
+ ) {
+ AsyncImage(
+ model = profileImageUrl?.takeIf { it.isNotBlank() },
+ contentDescription = "프로필 이미지",
+ modifier = Modifier
+ .fillMaxSize()
+ .clip(RoundedCornerShape(25.dp)),
+ placeholder = defaultProfile,
+ error = defaultProfile,
+ fallback = defaultProfile,
+ contentScale = ContentScale.Crop,
+ )
+
+ if (showCallBadge) {
+ Image(
+ painter = painterResource(
+ id = kr.co.call.designsystem.R.drawable.img_call_logo,
+ ),
+ contentDescription = null,
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .size(16.dp)
+ .zIndex(1f),
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ProfileImagePreview() {
+ CallFromAiTheme {
+ ProfileImage(
+ profileImageUrl = null,
+ showCallBadge = true,
+ )
+ }
+}
diff --git a/core/designsystem/src/main/res/drawable/ic_home_alarm.xml b/core/designsystem/src/main/res/drawable/ic_home_alarm.xml
new file mode 100644
index 00000000..3465d818
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_home_alarm.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_home_call.xml b/core/designsystem/src/main/res/drawable/ic_home_call.xml
new file mode 100644
index 00000000..bb289d00
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_home_call.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_home_call_btn.xml b/core/designsystem/src/main/res/drawable/ic_home_call_btn.xml
new file mode 100644
index 00000000..a0ba0aae
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_home_call_btn.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_home_record.xml b/core/designsystem/src/main/res/drawable/ic_home_record.xml
new file mode 100644
index 00000000..9c702cc8
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_home_record.xml
@@ -0,0 +1,18 @@
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/img_call_logo.png b/core/designsystem/src/main/res/drawable/img_call_logo.png
new file mode 100644
index 00000000..d03833b3
Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_call_logo.png differ
diff --git a/core/designsystem/src/main/res/drawable/img_home_call_missed.png b/core/designsystem/src/main/res/drawable/img_home_call_missed.png
new file mode 100644
index 00000000..885c280b
Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_home_call_missed.png differ
diff --git a/core/designsystem/src/main/res/drawable/img_home_call_received.png b/core/designsystem/src/main/res/drawable/img_home_call_received.png
new file mode 100644
index 00000000..9db0113b
Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_home_call_received.png differ
diff --git a/core/designsystem/src/main/res/drawable/img_home_call_send.png b/core/designsystem/src/main/res/drawable/img_home_call_send.png
new file mode 100644
index 00000000..277db0da
Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_home_call_send.png differ
diff --git a/core/designsystem/src/main/res/drawable/img_profile_url_default.png b/core/designsystem/src/main/res/drawable/img_profile_url_default.png
new file mode 100644
index 00000000..9869a6f6
Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_profile_url_default.png differ
diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts
index ebaea55f..53aaab19 100644
--- a/core/domain/build.gradle.kts
+++ b/core/domain/build.gradle.kts
@@ -4,4 +4,7 @@ plugins {
dependencies {
// Coroutines는 KotlinLibraryConventionPlugin에서 자동 추가됨
-}
\ No newline at end of file
+
+ // Hilt 생성자 주입(@Inject)에 필요한 JSR-330 어노테이션
+ implementation("javax.inject:javax.inject:1")
+}
diff --git a/core/domain/src/main/java/kr/co/call/domain/model/home/.gitkeep b/core/domain/src/main/java/kr/co/call/domain/model/home/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/core/domain/src/main/java/kr/co/call/domain/model/home/CallHistory.kt b/core/domain/src/main/java/kr/co/call/domain/model/home/CallHistory.kt
new file mode 100644
index 00000000..2fe3f0f1
--- /dev/null
+++ b/core/domain/src/main/java/kr/co/call/domain/model/home/CallHistory.kt
@@ -0,0 +1,12 @@
+package kr.co.call.domain.model.home
+
+import java.time.LocalDateTime
+
+data class CallHistory(
+ val callId: Long,
+ val characterName: String,
+ val aiSummary: String,
+ val startedAt: LocalDateTime,
+ val isOutgoing: Boolean,
+ val isMissed: Boolean,
+)
diff --git a/core/domain/src/main/java/kr/co/call/domain/model/home/CallReservation.kt b/core/domain/src/main/java/kr/co/call/domain/model/home/CallReservation.kt
new file mode 100644
index 00000000..dbcf810b
--- /dev/null
+++ b/core/domain/src/main/java/kr/co/call/domain/model/home/CallReservation.kt
@@ -0,0 +1,11 @@
+package kr.co.call.domain.model.home
+
+import java.time.LocalDateTime
+
+data class CallReservation(
+ val id: Long,
+ val characterId: Long,
+ val firstName: String,
+ val imageUrl: String?,
+ val scheduledAt: LocalDateTime,
+)
diff --git a/core/domain/src/main/java/kr/co/call/domain/model/home/CallReservations.kt b/core/domain/src/main/java/kr/co/call/domain/model/home/CallReservations.kt
new file mode 100644
index 00000000..9b76a00a
--- /dev/null
+++ b/core/domain/src/main/java/kr/co/call/domain/model/home/CallReservations.kt
@@ -0,0 +1,6 @@
+package kr.co.call.domain.model.home
+
+data class CallReservations(
+ val totalCount: Int,
+ val items: List,
+)
diff --git a/core/domain/src/main/java/kr/co/call/domain/model/home/HomeNotification.kt b/core/domain/src/main/java/kr/co/call/domain/model/home/HomeNotification.kt
new file mode 100644
index 00000000..3d9b4cc0
--- /dev/null
+++ b/core/domain/src/main/java/kr/co/call/domain/model/home/HomeNotification.kt
@@ -0,0 +1,22 @@
+package kr.co.call.domain.model.home
+
+import java.time.LocalDateTime
+
+data class HomeNotification(
+ val notificationId: Long,
+ val type: NotificationType,
+ val title: String,
+ val content: String,
+ val isRead: Boolean,
+ val createdAt: LocalDateTime,
+ val characterName: String?,
+ val profileImageUrl: String?,
+)
+
+enum class NotificationType(
+ val title: String,
+) {
+ MISSED_CALL(title = "부재중 전화"),
+ ANNIVERSARY(title = "기념일"),
+ CALL_RESERVATION(title = "통화 약속"),
+}
diff --git a/core/domain/src/main/java/kr/co/call/domain/model/home/HomeOverview.kt b/core/domain/src/main/java/kr/co/call/domain/model/home/HomeOverview.kt
new file mode 100644
index 00000000..b49f33f9
--- /dev/null
+++ b/core/domain/src/main/java/kr/co/call/domain/model/home/HomeOverview.kt
@@ -0,0 +1,10 @@
+package kr.co.call.domain.model.home
+
+/**
+ * home에서 조회할 데이터
+ */
+data class HomeOverview(
+ val summary: HomeSummary,
+ val reservations: CallReservations,
+ val callHistories: List,
+)
diff --git a/core/domain/src/main/java/kr/co/call/domain/model/home/HomeSummary.kt b/core/domain/src/main/java/kr/co/call/domain/model/home/HomeSummary.kt
new file mode 100644
index 00000000..ef1d9c14
--- /dev/null
+++ b/core/domain/src/main/java/kr/co/call/domain/model/home/HomeSummary.kt
@@ -0,0 +1,8 @@
+package kr.co.call.domain.model.home
+
+data class HomeSummary(
+ val firstName: String,
+ val relationshipDays: Int,
+ val totalCallCount: Int,
+ val callStreakDays: Int,
+)
diff --git a/core/domain/src/main/java/kr/co/call/domain/repository/HomeRepository.kt b/core/domain/src/main/java/kr/co/call/domain/repository/HomeRepository.kt
new file mode 100644
index 00000000..b5cf9614
--- /dev/null
+++ b/core/domain/src/main/java/kr/co/call/domain/repository/HomeRepository.kt
@@ -0,0 +1,19 @@
+package kr.co.call.domain.repository
+
+import kr.co.call.domain.model.home.CallHistory
+import kr.co.call.domain.model.home.CallReservations
+import kr.co.call.domain.model.home.HomeSummary
+import java.time.LocalDateTime
+
+interface HomeRepository {
+ suspend fun getReservations(): Result
+
+ suspend fun getCallHistories(): Result>
+
+ suspend fun getSummary(): Result
+
+ suspend fun changeReservationTime(
+ reservationId: Long,
+ scheduledAt: LocalDateTime,
+ ): Result
+}
diff --git a/core/domain/src/main/java/kr/co/call/domain/repository/gitkeep b/core/domain/src/main/java/kr/co/call/domain/repository/gitkeep
deleted file mode 100644
index 945c9b46..00000000
--- a/core/domain/src/main/java/kr/co/call/domain/repository/gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-.
\ No newline at end of file
diff --git a/core/domain/src/main/java/kr/co/call/domain/usecase/gitkeep b/core/domain/src/main/java/kr/co/call/domain/usecase/gitkeep
deleted file mode 100644
index 945c9b46..00000000
--- a/core/domain/src/main/java/kr/co/call/domain/usecase/gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-.
\ No newline at end of file
diff --git a/core/domain/src/main/java/kr/co/call/domain/usecase/home/HomeUseCase.kt b/core/domain/src/main/java/kr/co/call/domain/usecase/home/HomeUseCase.kt
new file mode 100644
index 00000000..6226d5c9
--- /dev/null
+++ b/core/domain/src/main/java/kr/co/call/domain/usecase/home/HomeUseCase.kt
@@ -0,0 +1,22 @@
+package kr.co.call.domain.usecase.home
+
+import kr.co.call.domain.model.home.HomeOverview
+import kr.co.call.domain.repository.HomeRepository
+import javax.inject.Inject
+
+class HomeUseCase @Inject constructor(
+ private val homeRepository: HomeRepository,
+) {
+ suspend operator fun invoke(): HomeOverview {
+ val summary = homeRepository.getSummary().getOrThrow()
+ val reservations = homeRepository.getReservations().getOrThrow()
+ val callHistories = homeRepository.getCallHistories().getOrThrow()
+
+ return HomeOverview(
+ summary = summary,
+ reservations = reservations,
+ callHistories = callHistories,
+ )
+ }
+
+}
diff --git a/core/network/src/main/java/kr/co/call/network/api/HomeApi.kt b/core/network/src/main/java/kr/co/call/network/api/HomeApi.kt
new file mode 100644
index 00000000..c8feff0f
--- /dev/null
+++ b/core/network/src/main/java/kr/co/call/network/api/HomeApi.kt
@@ -0,0 +1,20 @@
+package kr.co.call.network.api
+
+import kr.co.call.network.dto.CallHistoryListResponseDto
+import kr.co.call.network.dto.HomeSummaryResponseDto
+import kr.co.call.network.dto.ReservationListResponseDto
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+// 서버 API 경로 확정 전 임시 경로 사용
+interface HomeApi {
+
+ @GET("api/v1/call-reservations")
+ suspend fun getReservations(): ReservationListResponseDto
+
+ @GET("api/v1/calls")
+ suspend fun getCallHistories(@Query("size") size: Int = 20): CallHistoryListResponseDto
+
+ @GET("api/v1/home/summary")
+ suspend fun getSummary(): HomeSummaryResponseDto
+}
diff --git a/core/network/src/main/java/kr/co/call/network/dto/CallHistoryListResponseDto.kt b/core/network/src/main/java/kr/co/call/network/dto/CallHistoryListResponseDto.kt
new file mode 100644
index 00000000..895b8d41
--- /dev/null
+++ b/core/network/src/main/java/kr/co/call/network/dto/CallHistoryListResponseDto.kt
@@ -0,0 +1,23 @@
+package kr.co.call.network.dto
+
+data class CallHistoryListResponseDto(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: CallHistoryPageDto,
+)
+
+data class CallHistoryPageDto(
+ val content: List,
+ val hasNext: Boolean,
+)
+
+data class CallHistoryDto(
+ val callId: Long,
+ val characterName: String,
+ val sender: String,
+ val aiSummary: String,
+ val startedAt: String,
+ val callType: String,
+ val status: String,
+)
diff --git a/core/network/src/main/java/kr/co/call/network/dto/HomeSummaryResponseDto.kt b/core/network/src/main/java/kr/co/call/network/dto/HomeSummaryResponseDto.kt
new file mode 100644
index 00000000..2ce534a3
--- /dev/null
+++ b/core/network/src/main/java/kr/co/call/network/dto/HomeSummaryResponseDto.kt
@@ -0,0 +1,15 @@
+package kr.co.call.network.dto
+
+data class HomeSummaryResponseDto(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: HomeSummaryDto,
+)
+
+data class HomeSummaryDto(
+ val firstName: String,
+ val relationshipDays: Int,
+ val totalCallCount: Int,
+ val callStreakDays: Int,
+)
diff --git a/core/network/src/main/java/kr/co/call/network/dto/ReservationListResponseDto.kt b/core/network/src/main/java/kr/co/call/network/dto/ReservationListResponseDto.kt
new file mode 100644
index 00000000..cc0cecfd
--- /dev/null
+++ b/core/network/src/main/java/kr/co/call/network/dto/ReservationListResponseDto.kt
@@ -0,0 +1,22 @@
+package kr.co.call.network.dto
+
+data class ReservationListResponseDto(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: ReservationListDto,
+)
+
+data class ReservationListDto(
+ val content: List,
+ val count: Int,
+)
+
+data class ReservationDto(
+ val callReservationId: Long,
+ val characterId: Long,
+ val firstName: String,
+ val imageUrl: String?,
+ val scheduledAt: String,
+ val status: String,
+)
diff --git a/feature/home/impl/build.gradle.kts b/feature/home/impl/build.gradle.kts
index df7f69d9..200826f2 100644
--- a/feature/home/impl/build.gradle.kts
+++ b/feature/home/impl/build.gradle.kts
@@ -14,6 +14,7 @@ dependencies {
implementation(project(":feature:home:api"))
// core 모듈
+ implementation(project(":core:common"))
implementation(project(":core:designsystem"))
implementation(project(":core:domain"))
@@ -28,6 +29,10 @@ dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.hilt.navigation.compose)
+ implementation(libs.androidx.paging.compose)
+
+ // coil
+ implementation(libs.coil.compose)
// timber
implementation(libs.timber)
@@ -36,4 +41,4 @@ dependencies {
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
-}
\ No newline at end of file
+}
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/component/.gitkeep b/feature/home/impl/src/main/java/kr/co/call/impl/component/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/component/CharacterChangeDialog.kt b/feature/home/impl/src/main/java/kr/co/call/impl/component/CharacterChangeDialog.kt
new file mode 100644
index 00000000..44cf3273
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/component/CharacterChangeDialog.kt
@@ -0,0 +1,11 @@
+package kr.co.call.impl.component
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+@Composable
+fun CharacterChangeDialog(
+ modifier: Modifier = Modifier,
+){
+
+}
\ No newline at end of file
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/component/UnreadIndicator.kt b/feature/home/impl/src/main/java/kr/co/call/impl/component/UnreadIndicator.kt
new file mode 100644
index 00000000..1dab5c2c
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/component/UnreadIndicator.kt
@@ -0,0 +1,38 @@
+package kr.co.call.impl.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import kr.co.call.designsystem.theme.CallFromAiTheme
+import kr.co.call.designsystem.theme.CallTheme
+
+/**
+ * 읽지 않은 상태를 표시하는 컴포넌트
+ * - 홈 헤더와 알림 목록에서 공통 사용
+ */
+@Composable
+fun UnreadIndicator(
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier
+ .size(6.dp)
+ .background(
+ color = CallTheme.colors.subRed,
+ shape = CircleShape,
+ ),
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun UnreadIndicatorPreview() {
+ CallFromAiTheme {
+ UnreadIndicator()
+ }
+}
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/component/header/HomeHeader.kt b/feature/home/impl/src/main/java/kr/co/call/impl/component/header/HomeHeader.kt
new file mode 100644
index 00000000..786c75c4
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/component/header/HomeHeader.kt
@@ -0,0 +1,221 @@
+package kr.co.call.impl.component.header
+
+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.PaddingValues
+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.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kr.co.call.designsystem.R
+import kr.co.call.designsystem.theme.CallFromAiTheme
+import kr.co.call.designsystem.theme.CallTheme
+import kr.co.call.impl.component.RelationshipSummaryCard
+import kr.co.call.impl.component.UnreadIndicator
+import kr.co.call.impl.viewmodel.model.HomeSummaryUiModel
+
+/**
+ * 홈 화면 상단 헤더 컴포넌트
+ * - 사용자 인사말과 알림 상태 표시
+ * - 전화 버튼과 관계 요약 카드 포함
+ */
+@Composable
+fun HomeHeader(
+ summary: HomeSummaryUiModel,
+ hasUnreadNotification: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(312.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(265.dp)
+ .background(CallTheme.colors.mainVariant3),
+ )
+
+ Row(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(
+ top = 7.dp,
+ end = 3.dp,
+ ),
+ horizontalArrangement = Arrangement.spacedBy(3.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // 메인 연인 교체하기 버튼
+ Button(
+ onClick = {},
+ modifier = Modifier.size(
+ width = 93.dp,
+ height = 24.dp,
+ ),
+ shape = RoundedCornerShape(50.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = CallTheme.colors.background.copy(alpha = 0.6f),
+ contentColor = CallTheme.colors.gray800,
+ ),
+ contentPadding = PaddingValues(
+ horizontal = 4.dp,
+ vertical = 3.dp,
+ ),
+ ) {
+ Text(
+ text = "메인 연인 교체하기",
+ style = CallTheme.typography.caption.copy(
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ )
+ }
+
+ Box(
+ modifier = Modifier.size(48.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(modifier = Modifier.size(24.dp)) {
+ // 알림 아이콘
+ Icon(
+ painter = painterResource(
+ id = R.drawable.ic_home_alarm,
+ ),
+ contentDescription = "알림",
+ modifier = Modifier
+ .align(Alignment.Center)
+ .size(
+ width = 19.dp,
+ height = 21.dp,
+ ),
+ tint = Color.Unspecified,
+ )
+
+ // 알림 읽음 여부 표시
+ if (hasUnreadNotification) {
+ UnreadIndicator(
+ modifier = Modifier
+ .align(Alignment.TopEnd),
+ )
+ }
+ }
+ }
+ }
+
+ // 헤더 텍스트 영역
+ Column(
+ modifier = Modifier.padding(
+ start = 16.dp,
+ top = 80.dp,
+ end = 16.dp,
+ ),
+ ) {
+ Text(
+ text = buildAnnotatedString {
+ withStyle(
+ style = SpanStyle(fontWeight = FontWeight.SemiBold),
+ ) {
+ append(summary.firstName)
+ }
+ append("님, 반가워요! 👋🏻")
+ },
+ color = CallTheme.colors.white,
+ style = CallTheme.typography.titleSmall,
+ )
+
+ Text(
+ text = "오늘은 어떤 이야기를\n나누게 될까요?",
+ color = CallTheme.colors.white,
+ style = CallTheme.typography.titleMediumBold,
+ )
+ }
+
+ // 전화하기 버튼
+ FilledIconButton(
+ onClick = {},
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(
+ top = 133.dp,
+ end = 19.dp,
+ )
+ .size(53.dp),
+ shape = CircleShape,
+ colors = IconButtonDefaults.filledIconButtonColors(
+ containerColor = CallTheme.colors.background,
+ contentColor = CallTheme.colors.mainVariant1,
+ ),
+ ) {
+ // 전화하기 버튼
+ Icon(
+ painter = painterResource(
+ id = R.drawable.ic_home_call,
+ ),
+ contentDescription = "전화하기",
+ modifier = Modifier.fillMaxSize(),
+ tint = Color.Unspecified,
+ )
+ }
+
+ // 관계 요약
+ RelationshipSummaryCard(
+ summary = summary,
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .offset(y = 217.dp)
+ .width(350.dp)
+ .height(94.dp),
+ )
+
+
+ }
+}
+
+/**
+ * 홈 헤더 Preview
+ * - 사용자와 관계 요약 샘플 데이터 표시
+ * - 읽지 않은 알림 상태 확인
+ */
+@Preview(showBackground = true, widthDp = 412)
+@Composable
+private fun HomeHeaderPreview() {
+ CallFromAiTheme {
+ HomeHeader(
+ summary = HomeSummaryUiModel(
+ firstName = "수현",
+ relationshipDaysText = "30일째",
+ totalCallCountText = "24회",
+ callStreakDaysText = "12일",
+ ),
+ hasUnreadNotification = true,
+ )
+ }
+}
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/component/header/HomeReservationCard.kt b/feature/home/impl/src/main/java/kr/co/call/impl/component/header/HomeReservationCard.kt
new file mode 100644
index 00000000..97534c34
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/component/header/HomeReservationCard.kt
@@ -0,0 +1,247 @@
+package kr.co.call.impl.component.header
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+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.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.LocalMinimumInteractiveComponentSize
+import androidx.compose.material3.Text
+import androidx.compose.material3.VerticalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import java.time.LocalDate
+import kr.co.call.core.common.util.TimeUtil
+import kr.co.call.designsystem.theme.CallFromAiTheme
+import kr.co.call.designsystem.theme.CallTheme
+import kr.co.call.designsystem.component.ProfileImage
+import kr.co.call.impl.viewmodel.model.HomeReservationUiModel
+
+/**
+ * 홈 화면의 통화 약속 컴포넌트
+ * - 날짜와 전체 약속 건수는 항상 표시
+ * - 약속이 있으면 상대 정보와 시간 변경 버튼 표시
+ */
+@Composable
+fun HomeReservationCard(
+ reservation: HomeReservationUiModel,
+ modifier: Modifier = Modifier,
+ onTimeChangeClick: () -> Unit = {},
+) {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(140.dp)
+ .background(CallTheme.colors.background),
+ ) {
+ // 날짜 항상 표시
+ ReservationDate(
+ reservationCountText = reservation.reservationCountText,
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(
+ start = 35.dp,
+ top = 23.dp,
+ ),
+ )
+
+ // 예약이 있을 때만 카드 상세 표시
+ if (reservation.hasReservation) {
+ ReservationContent(
+ reservation = reservation,
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(
+ start = 34.dp,
+ top = 61.dp,
+ ),
+ )
+
+ reservation.scheduledTimeText?.let { scheduledTimeText ->
+ ReservationTimeActions(
+ scheduledTimeText = scheduledTimeText,
+ onTimeChangeClick = onTimeChangeClick,
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(
+ top = 56.dp,
+ end = 24.dp,
+ ),
+ )
+ }
+ }
+ }
+}
+
+/**
+ * 약속 날짜 영역 컴포넌트
+ * - 오늘 날짜와 전체 약속 건수 표시
+ */
+@Composable
+private fun ReservationDate(
+ reservationCountText: String,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = TimeUtil.toHomeDateText(LocalDate.now()),
+ color = CallTheme.colors.gray900,
+ style = CallTheme.typography.bodySmallBold,
+ )
+
+ Spacer(modifier = Modifier.width(10.dp))
+
+ VerticalDivider(
+ modifier = Modifier.height(21.dp),
+ thickness = 2.dp,
+ color = CallTheme.colors.gray100,
+ )
+
+ Spacer(modifier = Modifier.width(9.dp))
+
+ Text(
+ text = reservationCountText,
+ color = CallTheme.colors.mainVariant1,
+ style = CallTheme.typography.bodySmallBold,
+ )
+ }
+}
+
+/**
+ * 약속 상대 정보 컴포넌트
+ * - 프로필 이미지와 통화 로고 표시
+ * - 이름과 시간이 모두 있을 때 안내 문구 표시
+ */
+@Composable
+private fun ReservationContent(
+ reservation: HomeReservationUiModel,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ProfileImage(
+ profileImageUrl = reservation.profileImageUrl,
+ showCallBadge = true,
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ val firstName = reservation.firstName
+ val scheduledAtText = reservation.scheduledAtText
+
+ if (firstName != null && scheduledAtText != null) {
+ Text(
+ text = buildAnnotatedString {
+ withStyle(CallTheme.typography.bodyMediumMedium.toSpanStyle()) {
+ append(firstName)
+ }
+ append("님과 $scheduledAtText\n")
+ append("전화가 예정되어 있어요")
+ },
+ color = CallTheme.colors.black,
+ style = CallTheme.typography.bodyMedium,
+ )
+ }
+ }
+}
+
+/**
+ * 약속 시간 변경 영역 컴포넌트
+ * - 예약 시간을 24시간 형식으로 표시
+ * - 시간 변경 이벤트 전달
+ */
+@Composable
+private fun ReservationTimeActions(
+ scheduledTimeText: String,
+ onTimeChangeClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier.width(85.dp),
+ verticalArrangement = Arrangement.spacedBy(7.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(27.dp)
+ .background(
+ color = CallTheme.colors.mainVariant2,
+ shape = RoundedCornerShape(50.dp),
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = scheduledTimeText,
+ color = CallTheme.colors.mainVariant1,
+ style = CallTheme.typography.bodySmallBold,
+ )
+ }
+
+ // 시간 변경 버튼
+ Button(
+ onClick = onTimeChangeClick,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(27.dp),
+ shape = RoundedCornerShape(50.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = CallTheme.colors.white,
+ contentColor = CallTheme.colors.gray900,
+ ),
+ border = BorderStroke(
+ width = 1.dp,
+ color = CallTheme.colors.gray100,
+ ),
+ contentPadding = PaddingValues(0.dp),
+ ) {
+ Text(
+ text = "시간 변경",
+ style = CallTheme.typography.bodySmallBold,
+ )
+ }
+
+
+ }
+}
+
+/**
+ * 통화 약속 Preview
+ * - 약속이 한 건 있는 상태 확인
+ */
+@Preview(showBackground = true, widthDp = 412)
+@Composable
+private fun HomeReservationCardPreview() {
+ CallFromAiTheme {
+ HomeReservationCard(
+ reservation = HomeReservationUiModel(
+ hasReservation = true,
+ reservationCountText = "약속 1건",
+ profileImageUrl = null,
+ firstName = "민준",
+ scheduledAtText = "오늘 오후 9시",
+ scheduledTimeText = "21:00",
+ ),
+ )
+ }
+}
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/component/header/RelationshipSummaryCard.kt b/feature/home/impl/src/main/java/kr/co/call/impl/component/header/RelationshipSummaryCard.kt
new file mode 100644
index 00000000..d9d13194
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/component/header/RelationshipSummaryCard.kt
@@ -0,0 +1,131 @@
+package kr.co.call.impl.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.material3.VerticalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import kr.co.call.designsystem.theme.CallFromAiTheme
+import kr.co.call.designsystem.theme.CallTheme
+import kr.co.call.impl.viewmodel.model.HomeSummaryUiModel
+
+// 관계 요약
+/**
+ * 관계 정보를 요약하는 컴포넌트
+ * - 관계 일수와 통화 횟수 표시
+ * - 연속 통화 일수 표시
+ */
+@Composable
+fun RelationshipSummaryCard(
+ summary: HomeSummaryUiModel,
+ modifier: Modifier = Modifier,
+) {
+ val cardShape = RoundedCornerShape(20.dp)
+
+ Row(
+ modifier = modifier
+ .background(
+ color = CallTheme.colors.white,
+ shape = cardShape,
+ )
+ .border(
+ width = 1.dp,
+ color = CallTheme.colors.gray100,
+ shape = cardShape,
+ )
+ .padding(vertical = 15.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RelationshipSummaryItem(
+ label = "관계",
+ value = summary.relationshipDaysText,
+ modifier = Modifier.weight(1f),
+ )
+
+ RelationshipSummaryDivider()
+
+ RelationshipSummaryItem(
+ label = "통화",
+ value = summary.totalCallCountText,
+ modifier = Modifier.weight(1f),
+ )
+
+ RelationshipSummaryDivider()
+
+ RelationshipSummaryItem(
+ label = "연속 통화",
+ value = summary.callStreakDaysText,
+ modifier = Modifier.weight(1f),
+ )
+ }
+}
+
+/**
+ * 관계 요약 항목 컴포넌트
+ * - 항목명과 값을 세로로 표시
+ */
+@Composable
+private fun RelationshipSummaryItem(
+ label: String,
+ value: String,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = label,
+ color = CallTheme.colors.black,
+ style = CallTheme.typography.bodyMedium,
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = value,
+ color = CallTheme.colors.mainVariant1,
+ style = CallTheme.typography.bodyMediumMedium,
+ )
+ }
+}
+
+/**
+ * 관계 요약 구분선 컴포넌트
+ * - 항목 사이에 세로 구분선 표시
+ */
+@Composable
+private fun RelationshipSummaryDivider() {
+ VerticalDivider(
+ modifier = Modifier.height(63.dp),
+ thickness = 1.dp,
+ color = CallTheme.colors.gray100,
+ )
+}
+
+/**
+ * 관계 요약 카드 Preview
+ * - 관계와 통화 샘플 데이터 표시
+ */
+@Preview(showBackground = true, widthDp = 350)
+@Composable
+private fun RelationshipSummaryCardPreview() {
+ CallFromAiTheme {
+ RelationshipSummaryCard(
+ summary = HomeSummaryUiModel(
+ relationshipDaysText = "30일째",
+ totalCallCountText = "24회",
+ callStreakDaysText = "12일",
+ ),
+ modifier = Modifier.height(94.dp),
+ )
+ }
+}
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/component/history/CallHistoryList.kt b/feature/home/impl/src/main/java/kr/co/call/impl/component/history/CallHistoryList.kt
new file mode 100644
index 00000000..d42b5337
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/component/history/CallHistoryList.kt
@@ -0,0 +1,185 @@
+package kr.co.call.impl.component.history
+
+import androidx.compose.foundation.Image
+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.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.material3.HorizontalDivider
+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.painterResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import kr.co.call.designsystem.theme.CallFromAiTheme
+import kr.co.call.designsystem.theme.CallTheme
+import kr.co.call.impl.mock.CallMockData
+import kr.co.call.impl.viewmodel.model.CallHistoryIconType
+import kr.co.call.impl.viewmodel.model.CallHistoryUiModel
+
+/**
+ * 통화 기록 목록을 표시하는 컴포넌트
+ * - 최근 통화 기록 최대 20건 표시
+ * - 완료 여부에 따라 AI 요약과 기록 아이콘 노출
+ */
+@Composable
+fun CallHistoryList(
+ histories: List,
+ modifier: Modifier = Modifier,
+ onRecordClick: (Long) -> Unit = {},
+) {
+ Column(modifier = modifier.fillMaxWidth()) {
+ Text(
+ text = "최근 20건의 기록만 표시됩니다.",
+ modifier = Modifier.padding(
+ start = 16.dp,
+ top = 16.dp,
+ bottom = 18.dp,
+ ),
+ color = CallTheme.colors.gray800,
+ style = CallTheme.typography.bodySmall,
+ )
+
+ histories.forEach { history ->
+ CallHistoryCard(
+ callHistory = history,
+ onRecordClick = { onRecordClick(history.callId) },
+ )
+ }
+ }
+}
+
+/**
+ * 통화 기록 한 건을 표시하는 컴포넌트
+ * - 부재중 통화는 이름과 시간만 표시
+ * - 완료 통화는 AI 요약과 기록 이동 아이콘 표시
+ */
+@Composable
+private fun CallHistoryCard(
+ callHistory: CallHistoryUiModel,
+ modifier: Modifier = Modifier,
+ onRecordClick: () -> Unit = {},
+) {
+ // Missed type
+ val isMissed = callHistory.iconType == CallHistoryIconType.MISSED
+
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(if (isMissed) 70.dp else 90.dp),
+ ) {
+ CallHistoryIcon(
+ iconType = callHistory.iconType,
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(
+ start = 28.dp,
+ top = if (isMissed) 11.dp else 12.dp,
+ ),
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(
+ start = 68.dp,
+ top = if (isMissed) 11.dp else 10.dp,
+ end = if (isMissed) 16.dp else 74.dp,
+ ),
+ verticalArrangement = Arrangement.spacedBy(3.dp),
+ ) {
+ Text(
+ text = callHistory.characterName,
+ color = CallTheme.colors.black,
+ style = CallTheme.typography.bodySmallBold,
+ )
+
+ if (!isMissed) {
+ Text(
+ text = callHistory.aiSummary,
+ color = CallTheme.colors.gray800,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = CallTheme.typography.bodySmall,
+ )
+ }
+
+ Text(
+ text = callHistory.startedAtText,
+ color = CallTheme.colors.gray600,
+ style = CallTheme.typography.caption,
+ )
+ }
+
+ // 부재중 통화가 아닐 경우 기록 버튼 표시
+ if (!isMissed) {
+ Image(
+ painter = painterResource(
+ id = kr.co.call.designsystem.R.drawable.ic_home_record,
+ ),
+ contentDescription = "통화 기록 보기",
+ modifier = Modifier
+ .align(Alignment.CenterEnd)
+ .padding(end = 32.dp)
+ .size(42.dp)
+ .clickable(onClick = onRecordClick),
+ )
+ }
+
+ HorizontalDivider(
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .width(380.dp),
+ thickness = 1.dp,
+ color = CallTheme.colors.gray100,
+ )
+ }
+}
+
+// 아이콘 타입에 따라서 표시
+@Composable
+private fun CallHistoryIcon(
+ iconType: CallHistoryIconType,
+ modifier: Modifier = Modifier,
+) {
+ val iconResource = when (iconType) {
+ CallHistoryIconType.SENT ->
+ kr.co.call.designsystem.R.drawable.img_home_call_send
+
+ CallHistoryIconType.RECEIVED ->
+ kr.co.call.designsystem.R.drawable.img_home_call_received
+
+ CallHistoryIconType.MISSED ->
+ kr.co.call.designsystem.R.drawable.img_home_call_missed
+ }
+ val iconSize = when (iconType) {
+ CallHistoryIconType.SENT -> DpSize(26.dp, 23.dp)
+ CallHistoryIconType.RECEIVED -> DpSize(23.dp, 23.dp)
+ CallHistoryIconType.MISSED -> DpSize(25.dp, 26.dp)
+ }
+
+ Image(
+ painter = painterResource(id = iconResource),
+ contentDescription = null,
+ modifier = modifier.size(iconSize),
+ )
+}
+
+@Preview(showBackground = true, widthDp = 412, heightDp = 390)
+@Composable
+private fun CallHistoryListPreview() {
+ CallFromAiTheme {
+ CallHistoryList(
+ histories = CallMockData.uiModels,
+ )
+ }
+}
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/component/history/HomeHistorySection.kt b/feature/home/impl/src/main/java/kr/co/call/impl/component/history/HomeHistorySection.kt
new file mode 100644
index 00000000..0c9b56f8
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/component/history/HomeHistorySection.kt
@@ -0,0 +1,34 @@
+package kr.co.call.impl.component.history
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import kr.co.call.impl.tab.HomeHistoryTab
+import kr.co.call.impl.tab.HomeHistoryTabBar
+
+@Composable
+fun HomeHistorySection(
+ selectedTab: HomeHistoryTab,
+ onTabClick: (HomeHistoryTab) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Spacer(modifier = Modifier.height(22.dp))
+ HomeHistoryTabBar(
+ selectedTab = selectedTab,
+ onTabClick = onTabClick,
+ modifier = modifier,
+ )
+
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun HomeHistorySectionPreview(){
+ HomeHistoryTabBar(
+ selectedTab = HomeHistoryTab.NOTIFICATION,
+ onTabClick = {},
+ )
+}
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/component/history/NotificationList.kt b/feature/home/impl/src/main/java/kr/co/call/impl/component/history/NotificationList.kt
new file mode 100644
index 00000000..66adaf29
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/component/history/NotificationList.kt
@@ -0,0 +1,376 @@
+package kr.co.call.impl.component.history
+
+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.Column
+import androidx.compose.foundation.layout.PaddingValues
+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.lazy.LazyListScope
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+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.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.paging.compose.LazyPagingItems
+import androidx.paging.compose.itemKey
+import java.time.LocalDateTime
+import kr.co.call.designsystem.R
+import kr.co.call.designsystem.theme.CallFromAiTheme
+import kr.co.call.designsystem.theme.CallTheme
+import kr.co.call.designsystem.component.ProfileImage
+import kr.co.call.domain.model.home.HomeNotification
+import kr.co.call.domain.model.home.NotificationType
+import kr.co.call.impl.component.UnreadIndicator
+import kr.co.call.impl.mapper.toUiModel
+import kr.co.call.impl.viewmodel.model.HomeNotificationUiModel
+
+/**
+ * 알림 리스트 안내 문구
+ */
+@Composable
+fun NotificationListHeader(
+ modifier: Modifier = Modifier,
+) {
+ Text(
+ text = "최근 7일 간의 알림만 표시됩니다.",
+ modifier = modifier.padding(
+ start = 16.dp,
+ top = 16.dp,
+ bottom = 18.dp,
+ ),
+ color = CallTheme.colors.gray800,
+ style = CallTheme.typography.bodySmall,
+ )
+}
+
+/**
+ * 홈 LazyColumn에 Paging 알림 아이템 추가
+ */
+fun LazyListScope.notificationItems(
+ notifications: LazyPagingItems,
+) {
+ items(
+ count = notifications.itemCount,
+ key = notifications.itemKey { notification -> notification.notificationId },
+ ) { index ->
+ notifications[index]?.let { notification ->
+ NotificationCard(
+ notificationState = notification.toUiModel(),
+ onCallClick = {},
+ modifier = Modifier.padding(
+ start = 16.dp,
+ end = 16.dp,
+ bottom = 12.dp,
+ ),
+ )
+ }
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(12.dp))
+ }
+}
+
+/**
+ * 알림 내용 포함하는 공통 카드 컴포넌트
+ */
+@Composable
+private fun NotificationCard(
+ notificationState: HomeNotificationUiModel,
+ onCallClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val cardShape = RoundedCornerShape(20.dp)
+ val cardHeight = when (notificationState.type) {
+ NotificationType.CALL_RESERVATION -> 88.dp
+ NotificationType.ANNIVERSARY -> 109.dp
+ NotificationType.MISSED_CALL -> 129.dp
+ }
+ val borderColor = when {
+ notificationState.type == NotificationType.ANNIVERSARY -> CallTheme.colors.gray100
+ notificationState.isRead -> CallTheme.colors.gray100
+ else -> CallTheme.colors.mainVariant2
+ }
+
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(cardHeight)
+ .background(
+ color = CallTheme.colors.white,
+ shape = cardShape,
+ )
+ .border(
+ width = 1.dp,
+ color = borderColor,
+ shape = cardShape,
+ ),
+ ) {
+ // 기념일, 예약통화, 부재중 통화에 따라 분기
+ when (notificationState.type) {
+ NotificationType.ANNIVERSARY -> {
+ AnniversaryNotificationContent(
+ title = notificationState.type.title,
+ content = notificationState.content,
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(
+ start = 17.dp,
+ top = 23.dp,
+ end = 17.dp,
+ ),
+ )
+ }
+
+ NotificationType.CALL_RESERVATION -> {
+ CallNotificationContent(
+ title = notificationState.type.title,
+ content = notificationState.content,
+ profileImageUrl = notificationState.profileImageUrl,
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(
+ start = 17.dp,
+ top = 21.dp,
+ end = 17.dp,
+ ),
+ )
+ }
+
+ NotificationType.MISSED_CALL -> {
+ CallNotificationContent(
+ title = notificationState.type.title,
+ content = notificationState.content,
+ profileImageUrl = notificationState.profileImageUrl,
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(
+ start = 17.dp,
+ top = 21.dp,
+ end = 17.dp,
+ ),
+ )
+ }
+ }
+
+ if (notificationState.type == NotificationType.MISSED_CALL) {
+ // 부재중이라면 다시 전화하기 버튼 표시
+ Button(
+ onClick = onCallClick,
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(
+ end = 17.dp,
+ bottom = 20.dp,
+ )
+ .width(290.dp)
+ .height(32.dp),
+ shape = RoundedCornerShape(10.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = CallTheme.colors.mainVariant2,
+ contentColor = CallTheme.colors.black,
+ ),
+ contentPadding = PaddingValues(0.dp),
+ ) {
+ Icon(
+ painter = painterResource(
+ id = R.drawable.ic_home_call_btn,
+ ),
+ contentDescription = null,
+ modifier = Modifier.size(
+ width = 13.dp,
+ height = 12.dp,
+ ),
+ tint = CallTheme.colors.mainVariant1,
+ )
+
+ Spacer(modifier = Modifier.width(10.dp))
+
+ Text(
+ text = "다시 전화하기",
+ style = CallTheme.typography.captionBold,
+ )
+ }
+ }
+
+ // n분 전 표시
+ Text(
+ text = notificationState.createdAtText,
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(
+ top = 20.dp,
+ end = 17.dp,
+ ),
+ color = CallTheme.colors.gray400,
+ style = CallTheme.typography.caption,
+ )
+
+ // 알림 읽음 여부 표시
+ if (!notificationState.isRead) {
+ UnreadIndicator(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(
+ top = 10.dp,
+ end = 11.dp,
+ ),
+ )
+ }
+
+ }
+}
+
+@Composable
+private fun AnniversaryNotificationContent(
+ title: String,
+ content: String,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(15.dp),
+ verticalAlignment = Alignment.Top,
+ ) {
+ // 기념일 아이콘
+ Box(
+ modifier = Modifier
+ .size(41.dp)
+ .background(
+ color = CallTheme.colors.mainVariant2,
+ shape = CircleShape,
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "\uD83D\uDDD3\uFE0F",
+ fontSize = 24.sp,
+ )
+ }
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(5.dp),
+ ) {
+ Text(
+ text = title,
+ color = CallTheme.colors.black,
+ style = CallTheme.typography.bodySmallBold,
+ )
+ Text(
+ text = content,
+ color = CallTheme.colors.gray600,
+ style = CallTheme.typography.bodySmall,
+ )
+ }
+ }
+}
+
+/**
+ * 통화 약속, 예약 통화 타입 시 사용하는 공통 컴포넌트
+ */
+@Composable
+private fun CallNotificationContent(
+ title: String,
+ content: String,
+ profileImageUrl: String?,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.Top,
+ ) {
+ ProfileImage(
+ profileImageUrl = profileImageUrl,
+ showCallBadge = true,
+ )
+
+ Column(
+ modifier = Modifier.padding(top = 1.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = title,
+ color = CallTheme.colors.black,
+ style = CallTheme.typography.bodySmallBold,
+ )
+ Text(
+ text = content,
+ color = CallTheme.colors.gray600,
+ style = CallTheme.typography.bodySmall,
+ )
+ }
+
+ }
+}
+
+@Preview(showBackground = true, widthDp = 412)
+@Composable
+private fun NotificationCardPreview() {
+ val now = LocalDateTime.now()
+
+ CallFromAiTheme {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ NotificationCard(
+ notificationState = HomeNotification(
+ notificationId = 1,
+ type = NotificationType.CALL_RESERVATION,
+ title = "통화 약속",
+ content = "오늘 밤 9시 통화 15분 뒤에 시작됩니다.",
+ isRead = false,
+ createdAt = now.minusMinutes(1),
+ characterName = "민준",
+ profileImageUrl = null,
+ ).toUiModel(),
+ onCallClick = {},
+ )
+
+ NotificationCard(
+ notificationState = HomeNotification(
+ notificationId = 2,
+ type = NotificationType.MISSED_CALL,
+ title = "부재중 전화",
+ content = "민준이에게서 받지 못한 전화가 있어요.",
+ isRead = false,
+ createdAt = now.minusMinutes(30),
+ characterName = "민준",
+ profileImageUrl = null,
+ ).toUiModel(),
+ onCallClick = {},
+ )
+
+ NotificationCard(
+ notificationState = HomeNotification(
+ notificationId = 3,
+ type = NotificationType.ANNIVERSARY,
+ title = "기념일",
+ content = "오늘은 민준이와 함께한지 30일 째!\n작은 기념일을 함께 축하해요 💗",
+ isRead = true,
+ createdAt = now.minusHours(1),
+ characterName = "민준",
+ profileImageUrl = null,
+ ).toUiModel(),
+ onCallClick = {},
+ )
+ }
+ }
+}
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/entry/.gitkeep b/feature/home/impl/src/main/java/kr/co/call/impl/entry/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/mapper/CallHistoryUiMapper.kt b/feature/home/impl/src/main/java/kr/co/call/impl/mapper/CallHistoryUiMapper.kt
new file mode 100644
index 00000000..972363b3
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/mapper/CallHistoryUiMapper.kt
@@ -0,0 +1,22 @@
+package kr.co.call.impl.mapper
+
+import kr.co.call.core.common.util.TimeUtil
+import kr.co.call.domain.model.home.CallHistory
+import kr.co.call.impl.viewmodel.model.CallHistoryIconType
+import kr.co.call.impl.viewmodel.model.CallHistoryUiModel
+
+fun CallHistory.toUiModel(): CallHistoryUiModel =
+ CallHistoryUiModel(
+ callId = callId,
+ characterName = characterName,
+ aiSummary = aiSummary,
+ startedAtText = TimeUtil.toCallHistoryDateText(startedAt),
+ iconType = toIconType(),
+ )
+
+private fun CallHistory.toIconType(): CallHistoryIconType =
+ when {
+ isMissed -> CallHistoryIconType.MISSED
+ isOutgoing -> CallHistoryIconType.SENT
+ else -> CallHistoryIconType.RECEIVED
+ }
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/mapper/HomeNotificationUiMapper.kt b/feature/home/impl/src/main/java/kr/co/call/impl/mapper/HomeNotificationUiMapper.kt
new file mode 100644
index 00000000..dd653756
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/mapper/HomeNotificationUiMapper.kt
@@ -0,0 +1,17 @@
+package kr.co.call.impl.mapper
+
+import kr.co.call.core.common.util.TimeUtil
+import kr.co.call.domain.model.home.HomeNotification
+import kr.co.call.impl.viewmodel.model.HomeNotificationUiModel
+
+fun HomeNotification.toUiModel(): HomeNotificationUiModel =
+ HomeNotificationUiModel(
+ notificationId = notificationId,
+ type = type,
+ title = title,
+ content = content,
+ isRead = isRead,
+ createdAtText = TimeUtil.toTimeAgoText(createdAt),
+ characterName = characterName,
+ profileImageUrl = profileImageUrl,
+ )
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/mapper/HomeUiMapper.kt b/feature/home/impl/src/main/java/kr/co/call/impl/mapper/HomeUiMapper.kt
new file mode 100644
index 00000000..b62d3957
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/mapper/HomeUiMapper.kt
@@ -0,0 +1,31 @@
+package kr.co.call.impl.mapper
+
+import kr.co.call.core.common.util.TimeUtil
+import kr.co.call.domain.model.home.CallReservations
+import kr.co.call.domain.model.home.HomeSummary
+import kr.co.call.impl.viewmodel.model.HomeReservationUiModel
+import kr.co.call.impl.viewmodel.model.HomeSummaryUiModel
+
+fun HomeSummary.toUiModel(): HomeSummaryUiModel =
+ HomeSummaryUiModel(
+ firstName = firstName,
+ relationshipDaysText = relationshipDays.withSuffixOrDash("일째"),
+ totalCallCountText = totalCallCount.withSuffixOrDash("회"),
+ callStreakDaysText = callStreakDays.withSuffixOrDash("일"),
+ )
+
+fun CallReservations.toUiModel(): HomeReservationUiModel {
+ val reservation = items.firstOrNull()
+
+ return HomeReservationUiModel(
+ hasReservation = reservation != null,
+ reservationCountText = "약속 ${totalCount}건",
+ profileImageUrl = reservation?.imageUrl,
+ firstName = reservation?.firstName,
+ scheduledAtText = reservation?.scheduledAt?.let(TimeUtil::toReservationTimeText),
+ scheduledTimeText = reservation?.scheduledAt?.let(TimeUtil::toHourMinuteText),
+ )
+}
+
+private fun Int.withSuffixOrDash(suffix: String): String =
+ if (this == 0) "-" else "$this$suffix"
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/mock/CallMockData.kt b/feature/home/impl/src/main/java/kr/co/call/impl/mock/CallMockData.kt
new file mode 100644
index 00000000..6bfc2fef
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/mock/CallMockData.kt
@@ -0,0 +1,38 @@
+package kr.co.call.impl.mock
+
+import java.time.LocalDateTime
+import kr.co.call.domain.model.home.CallHistory
+import kr.co.call.impl.mapper.toUiModel
+import kr.co.call.impl.viewmodel.model.CallHistoryUiModel
+
+object CallMockData {
+ val histories: List = listOf(
+ CallHistory(
+ callId = 1L,
+ characterName = "민준",
+ aiSummary = "오늘 하루와 퇴근 후 일상 이야기",
+ startedAt = LocalDateTime.of(2026, 6, 28, 23, 2),
+ isOutgoing = true,
+ isMissed = false,
+ ),
+ CallHistory(
+ callId = 2L,
+ characterName = "민준",
+ aiSummary = "출근 준비와 아침 일정 이야기",
+ startedAt = LocalDateTime.of(2026, 6, 27, 7, 31),
+ isOutgoing = false,
+ isMissed = false,
+ ),
+ CallHistory(
+ callId = 3L,
+ characterName = "민준",
+ aiSummary = "",
+ startedAt = LocalDateTime.of(2026, 6, 26, 21, 29),
+ isOutgoing = false,
+ isMissed = true,
+ ),
+ )
+
+ val uiModels: List =
+ histories.map { callHistory -> callHistory.toUiModel() }
+}
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/mock/CharacterMockData.kt b/feature/home/impl/src/main/java/kr/co/call/impl/mock/CharacterMockData.kt
new file mode 100644
index 00000000..61bed570
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/mock/CharacterMockData.kt
@@ -0,0 +1,10 @@
+package kr.co.call.impl.mock
+
+import kr.co.call.impl.viewmodel.model.CharacterOptionUiModel
+
+object CharacterMockData {
+ val uiModels: List = listOf(
+ CharacterOptionUiModel(characterId = 1L, name = "민준", day = 30, isSelected = true),
+ CharacterOptionUiModel(characterId = 2L, name = "동휘", day = 12, isSelected = false),
+ )
+}
\ No newline at end of file
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/screen/.gitkeep b/feature/home/impl/src/main/java/kr/co/call/impl/screen/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/screen/HomeMockData.kt b/feature/home/impl/src/main/java/kr/co/call/impl/screen/HomeMockData.kt
new file mode 100644
index 00000000..9c2ed872
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/screen/HomeMockData.kt
@@ -0,0 +1,98 @@
+package kr.co.call.impl.screen
+
+import androidx.paging.PagingData
+import java.time.LocalDateTime
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+import kr.co.call.domain.model.home.CallHistory
+import kr.co.call.domain.model.home.CallReservation
+import kr.co.call.domain.model.home.CallReservations
+import kr.co.call.domain.model.home.HomeNotification
+import kr.co.call.domain.model.home.HomeSummary
+import kr.co.call.domain.model.home.NotificationType
+import kr.co.call.impl.mapper.toUiModel
+import kr.co.call.impl.mock.CallMockData
+import kr.co.call.impl.viewmodel.model.CallHistoryUiModel
+import kr.co.call.impl.viewmodel.model.HomeReservationUiModel
+import kr.co.call.impl.viewmodel.model.HomeSummaryUiModel
+
+/**
+ * 홈화면에서 사용하는 Mock Data
+ */
+data class HomeMockData(
+ val summary: HomeSummary,
+ val reservations: CallReservations,
+ val callHistories: List,
+ val hasUnreadNotification: Boolean,
+ val notifications: List,
+) {
+ val summaryUiModel: HomeSummaryUiModel
+ get() = summary.toUiModel()
+
+ val reservationUiModel: HomeReservationUiModel
+ get() = reservations.toUiModel()
+
+ val callHistoryUiModels: List
+ get() = callHistories.map { callHistory -> callHistory.toUiModel() }
+
+ fun notificationFlow(): Flow> =
+ flowOf(PagingData.from(notifications))
+}
+
+fun createHomeMockData(
+ now: LocalDateTime = LocalDateTime.now(),
+): HomeMockData =
+ HomeMockData(
+ summary = HomeSummary(
+ firstName = "수현",
+ relationshipDays = 30,
+ totalCallCount = 24,
+ callStreakDays = 12,
+ ),
+ reservations = CallReservations(
+ totalCount = 2,
+ items = listOf(
+ CallReservation(
+ id = 3L,
+ characterId = 2L,
+ firstName = "민준",
+ imageUrl = null,
+ scheduledAt = now.withHour(21).withMinute(0).withSecond(0),
+ ),
+ ),
+ ),
+ callHistories = CallMockData.histories,
+ hasUnreadNotification = true,
+ notifications = listOf(
+ HomeNotification(
+ notificationId = 42L,
+ type = NotificationType.CALL_RESERVATION,
+ title = "통화 약속",
+ content = "오늘 밤 9시 통화 15분 뒤에 시작됩니다.",
+ isRead = false,
+ createdAt = now.minusMinutes(1),
+ characterName = "민준",
+ profileImageUrl = "https://cdn.lovecall.com/presets/male/1.jpg",
+ ),
+ HomeNotification(
+ notificationId = 41L,
+ type = NotificationType.MISSED_CALL,
+ title = "부재중 전화",
+ content = "민준이에게서 받지 못한 전화가 있어요.",
+ isRead = false,
+ createdAt = now.minusMinutes(30),
+ characterName = "민준",
+ profileImageUrl = "https://cdn.lovecall.com/presets/male/1.jpg",
+ ),
+ HomeNotification(
+ notificationId = 40L,
+ type = NotificationType.ANNIVERSARY,
+ title = "기념일",
+ content = "오늘은 민준이와 함께한지 30일 째!\n작은 기념일을 함께 축하해요 💗",
+ isRead = true,
+ createdAt = now.minusHours(1),
+ characterName = "민준",
+ profileImageUrl = null,
+ ),
+ ),
+ )
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/screen/HomeScreen.kt b/feature/home/impl/src/main/java/kr/co/call/impl/screen/HomeScreen.kt
index 07660659..525d5443 100644
--- a/feature/home/impl/src/main/java/kr/co/call/impl/screen/HomeScreen.kt
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/screen/HomeScreen.kt
@@ -1,18 +1,162 @@
package kr.co.call.impl.screen
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.material3.Text
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.paging.PagingData
+import androidx.paging.compose.collectAsLazyPagingItems
+import java.time.LocalDateTime
+import kotlinx.coroutines.flow.Flow
+import kr.co.call.designsystem.theme.CallFromAiTheme
+import kr.co.call.designsystem.theme.CallTheme
+import kr.co.call.domain.model.home.HomeNotification
+import kr.co.call.impl.component.header.HomeHeader
+import kr.co.call.impl.component.header.HomeReservationCard
+import kr.co.call.impl.component.history.CallHistoryList
+import kr.co.call.impl.component.history.HomeHistorySection
+import kr.co.call.impl.component.history.NotificationListHeader
+import kr.co.call.impl.component.history.notificationItems
+import kr.co.call.impl.tab.HomeHistoryTab
+import kr.co.call.impl.viewmodel.HomeIntent
+import kr.co.call.impl.viewmodel.HomeViewModel
+import kr.co.call.impl.viewmodel.model.CallHistoryUiModel
+import kr.co.call.impl.viewmodel.model.HomeReservationUiModel
+import kr.co.call.impl.viewmodel.model.HomeSummaryUiModel
+import org.orbitmvi.orbit.compose.collectAsState
+/**
+ * 홈 화면 진입 컴포넌트
+ * - ViewModel 상태 구독
+ * - 헤더와 약속 상태를 화면 콘텐츠에 전달
+ */
@Composable
fun HomeScreen(
modifier: Modifier = Modifier,
+ viewModel: HomeViewModel = hiltViewModel(),
) {
- Box(
- modifier = modifier.fillMaxSize()
+ val state by viewModel.collectAsState()
+ val notifications = remember { createHomeMockData().notificationFlow() }
+
+ HomeScreenContent(
+ homeSummary = state.summary,
+ hasUnreadNotification = state.hasUnreadNotification,
+ homeReservation = state.reservation,
+ callHistories = state.callHistories,
+ selectedHistoryTab = state.selectedHistoryTab,
+ notifications = notifications,
+ onHistoryTabClick = { tab ->
+ viewModel.handleIntent(HomeIntent.SelectHistoryTab(tab))
+ },
+ modifier = modifier,
+ )
+}
+
+/**
+ * 홈 화면 콘텐츠 컴포넌트
+ * - 홈 헤더와 약속 배너 배치
+ * - 배경과 영역 구분선 표시
+ */
+@Composable
+fun HomeScreenContent(
+ homeSummary: HomeSummaryUiModel,
+ hasUnreadNotification: Boolean,
+ homeReservation: HomeReservationUiModel,
+ callHistories: List,
+ selectedHistoryTab: HomeHistoryTab,
+ notifications: Flow>,
+ onHistoryTabClick: (HomeHistoryTab) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val lazyNotificationItems = notifications.collectAsLazyPagingItems()
+
+ LazyColumn(
+ modifier = modifier
+ .fillMaxSize()
+ .background(CallTheme.colors.background),
) {
- Text("홈 화면")
+ item {
+ HomeHeader(
+ summary = homeSummary,
+ hasUnreadNotification = hasUnreadNotification,
+ )
+ }
+
+ item {
+ // 예약된 전화 조회
+ HomeReservationCard(reservation = homeReservation)
+ }
+
+ item {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(7.dp)
+ .background(CallTheme.colors.gray100),
+ )
+ }
+
+ item {
+ // 지난 알림, 통화 기록 표시 섹션
+ HomeHistorySection(
+ selectedTab = selectedHistoryTab,
+ onTabClick = onHistoryTabClick,
+ )
+ }
+
+ // 탭 선택 상태에 따라서 표시
+ when (selectedHistoryTab) {
+ HomeHistoryTab.NOTIFICATION -> {
+ item {
+ NotificationListHeader()
+ }
+ notificationItems(lazyNotificationItems)
+ }
+
+ HomeHistoryTab.CALL_HISTORY -> {
+ item {
+ CallHistoryList(histories = callHistories)
+ }
+ }
+ }
+ }
+}
+
+/**
+ * 홈 화면 Preview
+ */
+@Preview(showBackground = true, widthDp = 412)
+@Composable
+private fun HomeScreenContentPreview() {
+ val mockData = remember {
+ createHomeMockData(
+ now = LocalDateTime.of(2026, 7, 17, 15, 0),
+ )
+ }
+ var selectedHistoryTab by remember {
+ mutableStateOf(HomeHistoryTab.CALL_HISTORY)
+ }
+
+ CallFromAiTheme {
+ HomeScreenContent(
+ homeSummary = mockData.summaryUiModel,
+ hasUnreadNotification = mockData.hasUnreadNotification,
+ homeReservation = mockData.reservationUiModel,
+ callHistories = mockData.callHistoryUiModels,
+ selectedHistoryTab = selectedHistoryTab,
+ notifications = mockData.notificationFlow(),
+ onHistoryTabClick = { selectedHistoryTab = it },
+ )
}
-}
\ No newline at end of file
+}
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/tab/HomeHistoryTab.kt b/feature/home/impl/src/main/java/kr/co/call/impl/tab/HomeHistoryTab.kt
new file mode 100644
index 00000000..04df134d
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/tab/HomeHistoryTab.kt
@@ -0,0 +1,11 @@
+package kr.co.call.impl.tab
+
+/**
+ * 지난알림, 통화 기록 탭
+ */
+enum class HomeHistoryTab(
+ val title: String,
+) {
+ NOTIFICATION(title = "지난 알림"),
+ CALL_HISTORY(title = "통화 기록"),
+}
\ No newline at end of file
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/tab/HomeHistoryTabBar.kt b/feature/home/impl/src/main/java/kr/co/call/impl/tab/HomeHistoryTabBar.kt
new file mode 100644
index 00000000..4ab815a0
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/tab/HomeHistoryTabBar.kt
@@ -0,0 +1,82 @@
+package kr.co.call.impl.tab
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.PrimaryScrollableTabRow
+import androidx.compose.material3.Tab
+import androidx.compose.material3.TabRowDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import kr.co.call.designsystem.theme.CallFromAiTheme
+import kr.co.call.designsystem.theme.CallTheme
+
+@Composable
+fun HomeHistoryTabBar(
+ selectedTab: HomeHistoryTab,
+ onTabClick: (HomeHistoryTab) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val tabs = HomeHistoryTab.entries
+ val selectedTabIndex = tabs.indexOf(selectedTab)
+
+ PrimaryScrollableTabRow(
+ selectedTabIndex = selectedTabIndex,
+ modifier = modifier.fillMaxWidth(),
+ edgePadding = 0.dp,
+ minTabWidth = 80.dp,
+ indicator = {
+ TabRowDefaults.PrimaryIndicator(
+ modifier = Modifier.tabIndicatorOffset(
+ selectedTabIndex = selectedTabIndex,
+ matchContentSize = true,
+ ),
+ width = Dp.Unspecified,
+ height = 2.dp,
+ color = CallTheme.colors.mainVariant1,
+ )
+ },
+ divider = {
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = CallTheme.colors.gray100,
+ )
+ },
+ ) {
+ tabs.forEach { tab ->
+ Tab(
+ selected = tab == selectedTab,
+ onClick = { onTabClick(tab) },
+ text = {
+ Text(
+ text = tab.title,
+ style = CallTheme.typography.bodyMediumMedium,
+ )
+ },
+ selectedContentColor = CallTheme.colors.mainVariant1,
+ unselectedContentColor = CallTheme.colors.gray800,
+
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true, widthDp = 412)
+@Composable
+private fun HomeHistoryTabBarPreview() {
+ var selectedTab by remember { mutableStateOf(HomeHistoryTab.NOTIFICATION) }
+
+ CallFromAiTheme {
+ HomeHistoryTabBar(
+ selectedTab = selectedTab,
+ onTabClick = { selectedTab = it },
+ )
+ }
+}
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/.gitkeep b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/HomeIntent.kt b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/HomeIntent.kt
new file mode 100644
index 00000000..0d99d3c7
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/HomeIntent.kt
@@ -0,0 +1,15 @@
+package kr.co.call.impl.viewmodel
+
+import kr.co.call.impl.tab.HomeHistoryTab
+
+sealed interface HomeIntent {
+ data class SelectHistoryTab(
+ val tab: HomeHistoryTab,
+ ) : HomeIntent
+
+ data object ClickCall : HomeIntent
+
+ data object ClickNotification : HomeIntent
+
+ data object ClickChangeTime : HomeIntent
+}
\ No newline at end of file
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/HomeSideEffect.kt b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/HomeSideEffect.kt
new file mode 100644
index 00000000..b4dd8352
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/HomeSideEffect.kt
@@ -0,0 +1,10 @@
+package kr.co.call.impl.viewmodel
+
+sealed interface HomeSideEffect {
+ data class ShowMessage(
+ val message: String,
+ val actionLabel: String? = null,
+ val onActionClick: (() -> Unit)? = null,
+ ) : HomeSideEffect
+
+}
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/HomeViewModel.kt b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/HomeViewModel.kt
new file mode 100644
index 00000000..1c535929
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/HomeViewModel.kt
@@ -0,0 +1,90 @@
+package kr.co.call.impl.viewmodel
+
+import androidx.lifecycle.ViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CancellationException
+import kr.co.call.domain.usecase.home.HomeUseCase
+import kr.co.call.domain.util.LoadStatus
+import kr.co.call.impl.mapper.toUiModel
+import kr.co.call.impl.tab.HomeHistoryTab
+import kr.co.call.impl.viewmodel.state.HomeState
+import org.orbitmvi.orbit.Container
+import org.orbitmvi.orbit.ContainerHost
+import org.orbitmvi.orbit.viewmodel.container
+import javax.inject.Inject
+
+@HiltViewModel
+class HomeViewModel @Inject constructor(
+ private val homeUseCase: HomeUseCase,
+) :
+ ViewModel(),
+ ContainerHost {
+
+ override val container: Container = container(
+ initialState = HomeState(),
+ )
+
+ init {
+ loadHome()
+ }
+
+ fun handleIntent(intent: HomeIntent) {
+ when (intent) {
+ is HomeIntent.SelectHistoryTab -> {
+ selectHistoryTab(intent.tab)
+ }
+
+ HomeIntent.ClickCall -> {
+ // 전화 화면 이동
+ }
+
+ HomeIntent.ClickNotification -> {
+ // 알림 화면 이동
+ }
+
+ HomeIntent.ClickChangeTime -> {
+ // 시간 변경 화면 이동
+ }
+ }
+ }
+
+ private fun loadHome() = intent {
+ reduce {
+ state.copy(loadStatus = LoadStatus.Loading)
+ }
+
+ try {
+ // 데이터 한번에 불러오기
+ val home = homeUseCase()
+ reduce {
+ state.copy(
+ summary = home.summary.toUiModel(),
+ reservation = home.reservations.toUiModel(),
+ callHistories = home.callHistories.map { history -> history.toUiModel() },
+ loadStatus = LoadStatus.Idle,
+ )
+ }
+ } catch (cancellationException: CancellationException) {
+ throw cancellationException
+ } catch (throwable: Throwable) {
+ reduce {
+ state.copy(
+ loadStatus = LoadStatus.Error(
+ message = throwable.message ?: "홈 정보를 불러오지 못했습니다.",
+ ),
+ )
+ }
+ }
+ }
+
+ // 기록 탭 선택
+ private fun selectHistoryTab(
+ tab: HomeHistoryTab,
+ ) = intent {
+ reduce {
+ state.copy(
+ selectedHistoryTab = tab,
+ )
+ }
+ }
+}
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/model/CallHistoryIconType.kt b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/model/CallHistoryIconType.kt
new file mode 100644
index 00000000..ff43b39b
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/model/CallHistoryIconType.kt
@@ -0,0 +1,7 @@
+package kr.co.call.impl.viewmodel.model
+
+enum class CallHistoryIconType {
+ SENT,
+ RECEIVED,
+ MISSED,
+}
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/model/CallHistoryUiModel.kt b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/model/CallHistoryUiModel.kt
new file mode 100644
index 00000000..19731863
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/model/CallHistoryUiModel.kt
@@ -0,0 +1,9 @@
+package kr.co.call.impl.viewmodel.model
+
+data class CallHistoryUiModel(
+ val callId: Long,
+ val characterName: String = "",
+ val aiSummary: String = "",
+ val startedAtText: String = "",
+ val iconType: CallHistoryIconType,
+)
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/model/CharacterOptionUiModel.kt b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/model/CharacterOptionUiModel.kt
new file mode 100644
index 00000000..4394ecde
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/model/CharacterOptionUiModel.kt
@@ -0,0 +1,9 @@
+package kr.co.call.impl.viewmodel.model
+
+data class CharacterOptionUiModel(
+ val characterId: Long,
+ val name: String,
+ val day: Int,
+ val imageUrl: String? = null,
+ val isSelected: Boolean = false,
+)
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/model/HomeNotificationUiModel.kt b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/model/HomeNotificationUiModel.kt
new file mode 100644
index 00000000..a890d9f8
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/model/HomeNotificationUiModel.kt
@@ -0,0 +1,14 @@
+package kr.co.call.impl.viewmodel.model
+
+import kr.co.call.domain.model.home.NotificationType
+
+data class HomeNotificationUiModel(
+ val notificationId: Long,
+ val type: NotificationType,
+ val title: String,
+ val content: String,
+ val isRead: Boolean,
+ val createdAtText: String,
+ val characterName: String?,
+ val profileImageUrl: String?,
+)
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/model/HomeReservationUiModel.kt b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/model/HomeReservationUiModel.kt
new file mode 100644
index 00000000..74f6eea8
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/model/HomeReservationUiModel.kt
@@ -0,0 +1,10 @@
+package kr.co.call.impl.viewmodel.model
+
+data class HomeReservationUiModel(
+ val hasReservation: Boolean = false,
+ val reservationCountText: String = "약속 0건",
+ val profileImageUrl: String? = null,
+ val firstName: String? = null,
+ val scheduledAtText: String? = null,
+ val scheduledTimeText: String? = null,
+)
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/model/HomeSummaryUiModel.kt b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/model/HomeSummaryUiModel.kt
new file mode 100644
index 00000000..6de37155
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/model/HomeSummaryUiModel.kt
@@ -0,0 +1,8 @@
+package kr.co.call.impl.viewmodel.model
+
+data class HomeSummaryUiModel(
+ val firstName: String = "",
+ val relationshipDaysText: String = "-",
+ val totalCallCountText: String = "-",
+ val callStreakDaysText: String = "-",
+)
diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/state/HomeState.kt b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/state/HomeState.kt
new file mode 100644
index 00000000..3dbc454c
--- /dev/null
+++ b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/state/HomeState.kt
@@ -0,0 +1,19 @@
+package kr.co.call.impl.viewmodel.state
+
+import kr.co.call.domain.util.LoadStatus
+import kr.co.call.impl.tab.HomeHistoryTab
+import kr.co.call.impl.viewmodel.model.CallHistoryUiModel
+import kr.co.call.impl.viewmodel.model.HomeReservationUiModel
+import kr.co.call.impl.viewmodel.model.HomeSummaryUiModel
+
+data class HomeState(
+ val summary: HomeSummaryUiModel = HomeSummaryUiModel(),
+ val reservation: HomeReservationUiModel = HomeReservationUiModel(),
+ val callHistories: List = emptyList(),
+ val hasUnreadNotification: Boolean = true,
+ val loadStatus: LoadStatus = LoadStatus.Idle,
+ val selectedHistoryTab: HomeHistoryTab = HomeHistoryTab.NOTIFICATION,
+) {
+ val isLoading: Boolean
+ get() = loadStatus == LoadStatus.Loading
+}