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 @@ - + \ 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 +}