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 607f746..d18bdcd 100644 --- a/app/src/main/java/kr/co/call/callfromai/AppScreen.kt +++ b/app/src/main/java/kr/co/call/callfromai/AppScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.ui.NavDisplay +import kr.co.call.api.CallRecordNavKey import kr.co.call.api.ChatRoomNavKey import kr.co.call.api.ChattingNavKey import kr.co.call.api.FaqNavKey @@ -107,7 +108,14 @@ fun AppScreen(modifier: Modifier = Modifier) { entryProvider = entryProvider { loginEntry() onboardingEntry() - homeEntry() + homeEntry( + navigateToCallRecord = { callId -> + appNavigator.navigate(CallRecordNavKey(callId = callId)) + }, + onCallRecordBack = { + appNavigator.popBackStack() + }, + ) chattingEntry( navigateToChatRoom = { roomId -> appNavigator.navigate(ChatRoomNavKey(roomId = roomId)) 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 421176f..6df6192 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 @@ -4,11 +4,14 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import kr.co.call.data.repositoryImpl.CallRecordMockRepository import kr.co.call.data.repositoryImpl.FaqRepositoryImpl import kr.co.call.data.repositoryImpl.ChatRepositoryImpl +import kr.co.call.data.repositoryImpl.HomeMockRepository +import kr.co.call.data.repositoryImpl.MyPageRepositoryImpl +import kr.co.call.domain.repository.CallRecordRepository import kr.co.call.domain.repository.ChatRepository -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.FaqRepository @@ -27,9 +30,15 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindHomeRepository( - homeRepositoryImpl: HomeRepositoryImpl, + homeMockRepository: HomeMockRepository, ): HomeRepository + @Binds + @Singleton + abstract fun bindCallRecordRepository( + callRecordMockRepository: CallRecordMockRepository, + ): CallRecordRepository + @Binds @Singleton abstract fun bindMyPageRepository( @@ -41,4 +50,4 @@ abstract class RepositoryModule { abstract fun bindFaqRepository( impl: FaqRepositoryImpl ): FaqRepository -} \ 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 index 4898d41..17fef8b 100644 --- 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 @@ -1,15 +1,8 @@ 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) @@ -37,26 +30,3 @@ private fun String.toIsMissed(): Boolean = "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/mapper/CallTranscriptMapper.kt b/core/data/src/main/java/kr/co/call/data/mapper/CallTranscriptMapper.kt new file mode 100644 index 0000000..597f1c8 --- /dev/null +++ b/core/data/src/main/java/kr/co/call/data/mapper/CallTranscriptMapper.kt @@ -0,0 +1,21 @@ +package kr.co.call.data.mapper + +import kr.co.call.domain.model.home.CallTranscript +import kr.co.call.network.dto.CallTranscriptDto +import kr.co.call.network.dto.CallTranscriptResultDto + +internal fun CallTranscriptResultDto.toDomain(): List = + content.map { transcript -> transcript.toDomain() } + +private fun CallTranscriptDto.toDomain(): CallTranscript = + CallTranscript( + content = content, + speaker = speaker.toDomainSpeaker(), + ) + +private fun String.toDomainSpeaker(): CallTranscript.Speaker = + when (this) { + "사용자", "USER" -> CallTranscript.Speaker.USER + "AI" -> CallTranscript.Speaker.AI + else -> error("지원하지 않는 화자입니다: $this") + } diff --git a/core/data/src/main/java/kr/co/call/data/mapper/HomeMapper.kt b/core/data/src/main/java/kr/co/call/data/mapper/HomeMapper.kt new file mode 100644 index 0000000..68b8139 --- /dev/null +++ b/core/data/src/main/java/kr/co/call/data/mapper/HomeMapper.kt @@ -0,0 +1,12 @@ +package kr.co.call.data.mapper + +import kr.co.call.domain.model.home.HomeSummary +import kr.co.call.network.dto.HomeSummaryDto + +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/mapper/ReservationMapper.kt b/core/data/src/main/java/kr/co/call/data/mapper/ReservationMapper.kt new file mode 100644 index 0000000..02752b4 --- /dev/null +++ b/core/data/src/main/java/kr/co/call/data/mapper/ReservationMapper.kt @@ -0,0 +1,22 @@ +package kr.co.call.data.mapper + +import java.time.LocalDateTime +import kr.co.call.domain.model.home.CallReservation +import kr.co.call.domain.model.home.CallReservations +import kr.co.call.network.dto.ReservationDto +import kr.co.call.network.dto.ReservationListDto + +internal fun ReservationListDto.toDomain(): CallReservations = + CallReservations( + totalCount = count, + items = content.map { reservation -> reservation.toDomain() }, + ) + +private fun ReservationDto.toDomain(): CallReservation = + CallReservation( + id = callReservationId, + characterId = characterId, + firstName = firstName, + imageUrl = imageUrl, + scheduledAt = LocalDateTime.parse(scheduledAt), + ) diff --git a/core/data/src/main/java/kr/co/call/data/repositoryImpl/CallRecordMockRepository.kt b/core/data/src/main/java/kr/co/call/data/repositoryImpl/CallRecordMockRepository.kt new file mode 100644 index 0000000..b31704a --- /dev/null +++ b/core/data/src/main/java/kr/co/call/data/repositoryImpl/CallRecordMockRepository.kt @@ -0,0 +1,62 @@ +package kr.co.call.data.repositoryImpl + +import java.time.LocalDateTime +import javax.inject.Inject +import kr.co.call.domain.model.home.CallInfo +import kr.co.call.domain.model.home.CallTranscript +import kr.co.call.domain.repository.CallRecordRepository + +class CallRecordMockRepository @Inject constructor() : CallRecordRepository { + + override suspend fun getCallInfo(callId: Long): Result { + if (callId !in 1L..20L) { + return callNotFound() + } + + return Result.success( + CallInfo( + callId = callId, + title = "출근 준비와 아침 일정 이야기", + calledAt = LocalDateTime.of(2026, 6, 27, 7, 31), + characterName = if (callId % 2L == 1L) "민준" else "동휘", + recordingUrl = + "https://storage.googleapis.com/exoplayer-test-media-0/play.mp3", + durationMillis = 0L, + ), + ) + } + + override suspend fun getCallScript( + callId: Long, + ): Result> { + if (callId !in 1L..20L) { + return callNotFound() + } + + return Result.success( + listOf( + CallTranscript( + content = "여보세요", + speaker = CallTranscript.Speaker.USER, + ), + CallTranscript( + content = "잘 일어났어? 목소리 아직 잠긴 것 같은데.", + speaker = CallTranscript.Speaker.AI, + ), + CallTranscript( + content = "응 방금 일어나 준비하고 있었어", + speaker = CallTranscript.Speaker.USER, + ), + CallTranscript( + content = "오늘 출근이지? 몸은 좀 괜찮아? 피곤해 보여.", + speaker = CallTranscript.Speaker.AI, + ), + ), + ) + } + + private fun callNotFound(): Result = + Result.failure( + IllegalArgumentException("통화 기록을 찾을 수 없습니다."), + ) +} diff --git a/core/data/src/main/java/kr/co/call/data/repositoryImpl/CallRecordRepositoryImpl.kt b/core/data/src/main/java/kr/co/call/data/repositoryImpl/CallRecordRepositoryImpl.kt new file mode 100644 index 0000000..e7afda4 --- /dev/null +++ b/core/data/src/main/java/kr/co/call/data/repositoryImpl/CallRecordRepositoryImpl.kt @@ -0,0 +1,21 @@ +package kr.co.call.data.repositoryImpl + +import javax.inject.Inject +import kr.co.call.domain.model.home.CallInfo +import kr.co.call.domain.model.home.CallTranscript +import kr.co.call.domain.repository.CallRecordRepository + +class CallRecordRepositoryImpl @Inject constructor() : CallRecordRepository { + + override suspend fun getCallInfo(callId: Long): Result = + unsupportedApi() + + override suspend fun getCallScript( + callId: Long, + ): Result> = unsupportedApi() + + private fun unsupportedApi(): Result = + Result.failure( + UnsupportedOperationException("통화 기록 서버 API가 아직 연결되지 않았습니다."), + ) +} diff --git a/core/data/src/main/java/kr/co/call/data/repositoryImpl/HomeMockRepository.kt b/core/data/src/main/java/kr/co/call/data/repositoryImpl/HomeMockRepository.kt new file mode 100644 index 0000000..4c4b6cc --- /dev/null +++ b/core/data/src/main/java/kr/co/call/data/repositoryImpl/HomeMockRepository.kt @@ -0,0 +1,139 @@ +package kr.co.call.data.repositoryImpl + +import java.time.LocalDateTime +import javax.inject.Inject +import kr.co.call.domain.exception.CharacterChangeUnavailableException +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.HomeCharacter +import kr.co.call.domain.model.home.HomeSummary +import kr.co.call.domain.repository.HomeRepository + +class HomeMockRepository @Inject constructor() : HomeRepository { + + private var characters = listOf( + HomeCharacter( + id = 1L, + name = "민준", + relationshipDays = 30, + imageUrl = null, + isMain = true, + ), + HomeCharacter( + id = 2L, + name = "동휘", + relationshipDays = 12, + imageUrl = null, + isMain = false, + ), + ) + + private var reservations = CallReservations( + totalCount = 1, + items = listOf( + CallReservation( + id = 3L, + characterId = 1L, + firstName = "민준", + imageUrl = null, + scheduledAt = LocalDateTime.of(2026, 6, 30, 21, 0), + ), + ), + ) + + override suspend fun getCharacters(): Result> = + Result.success(characters) + + override suspend fun getReservations(): Result = + Result.success(reservations) + + override suspend fun getCallHistories(): Result> = + Result.success( + List(20) { 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 { + val hasReservation = reservations.items.any { reservation -> + reservation.id == reservationId + } + if (!hasReservation) { + return Result.failure( + IllegalArgumentException("변경할 예약을 찾을 수 없습니다."), + ) + } + + reservations = reservations.copy( + items = reservations.items.map { reservation -> + if (reservation.id == reservationId) { + reservation.copy(scheduledAt = scheduledAt) + } else { + reservation + } + }, + ) + + return Result.success(reservations) + } + + override suspend fun changeMainCharacter( + characterId: Long, + ): Result> { + val hasCharacter = characters.any { character -> + character.id == characterId + } + if (!hasCharacter) { + return Result.failure( + IllegalArgumentException("변경할 캐릭터를 찾을 수 없습니다."), + ) + } + + return Result.failure(CharacterChangeUnavailableException()) + } + + override suspend fun startCall(characterId: Long): Result { + val isMainCharacter = characters.any { character -> + character.id == characterId && character.isMain + } + + return if (isMainCharacter) { + Result.success(Unit) + } else { + Result.failure( + IllegalStateException("메인 캐릭터와만 통화할 수 있습니다."), + ) + } + } +} 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 index 657cf53..1d4f4ad 100644 --- 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 @@ -2,84 +2,53 @@ package kr.co.call.data.repositoryImpl import java.time.LocalDateTime import javax.inject.Inject +import kr.co.call.data.mapper.toDomain 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.HomeCharacter import kr.co.call.domain.model.home.HomeSummary import kr.co.call.domain.repository.HomeRepository +import kr.co.call.network.api.HomeApi -class HomeRepositoryImpl @Inject constructor() : HomeRepository { +class HomeRepositoryImpl @Inject constructor( + private val homeApi: HomeApi, +) : HomeRepository { + + override suspend fun getCharacters(): Result> = + unsupportedApi() 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), - ), - ), - ), - ) + runCatching { + homeApi.getReservations().result.toDomain() + } 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, - ) - }, - ) + runCatching { + homeApi.getCallHistories().result.content.map { callHistory -> + callHistory.toDomain() + } + } override suspend fun getSummary(): Result = - Result.success( - HomeSummary( - firstName = "수현", - relationshipDays = 30, - totalCallCount = 24, - callStreakDays = 12, - ), - ) + runCatching { + homeApi.getSummary().result.toDomain() + } 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, - ), - ), - ), - ) + ): Result = unsupportedApi() + + override suspend fun changeMainCharacter( + characterId: Long, + ): Result> = unsupportedApi() - private companion object { - const val CALL_HISTORY_SIZE = 20 - } + override suspend fun startCall( + characterId: Long, + ): Result = unsupportedApi() + + private fun unsupportedApi(): Result = + Result.failure( + UnsupportedOperationException("서버 API가 아직 연결되지 않았습니다."), + ) } diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts index df0969c..05f7efb 100644 --- a/core/designsystem/build.gradle.kts +++ b/core/designsystem/build.gradle.kts @@ -19,4 +19,5 @@ dependencies { //timber implementation(libs.timber) + implementation(libs.androidx.ui.graphics) } \ No newline at end of file diff --git a/core/designsystem/src/main/res/drawable/ic_home_call_pause.xml b/core/designsystem/src/main/res/drawable/ic_home_call_pause.xml new file mode 100644 index 0000000..e7ca2f7 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_home_call_pause.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_home_call_play.xml b/core/designsystem/src/main/res/drawable/ic_home_call_play.xml new file mode 100644 index 0000000..a75c42c --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_home_call_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_home_call_replay_5.xml b/core/designsystem/src/main/res/drawable/ic_home_call_replay_5.xml new file mode 100644 index 0000000..939fb5c --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_home_call_replay_5.xml @@ -0,0 +1,16 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_home_call_replay_5_after.xml b/core/designsystem/src/main/res/drawable/ic_home_call_replay_5_after.xml new file mode 100644 index 0000000..4084949 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_home_call_replay_5_after.xml @@ -0,0 +1,16 @@ + + + + diff --git a/core/domain/src/main/java/kr/co/call/domain/exception/CharacterChangeUnavailableException.kt b/core/domain/src/main/java/kr/co/call/domain/exception/CharacterChangeUnavailableException.kt new file mode 100644 index 0000000..8db0bae --- /dev/null +++ b/core/domain/src/main/java/kr/co/call/domain/exception/CharacterChangeUnavailableException.kt @@ -0,0 +1,7 @@ +package kr.co.call.domain.exception + +/** + * 서버 정책상 메인 캐릭터를 아직 교체할 수 없을 때 발생하는 예외 + */ +class CharacterChangeUnavailableException : + IllegalStateException("아직 메인 연인을 변경할 수 없습니다.") 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 deleted file mode 100644 index 2fe3f0f..0000000 --- a/core/domain/src/main/java/kr/co/call/domain/model/home/CallHistory.kt +++ /dev/null @@ -1,12 +0,0 @@ -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/CallRecord.kt b/core/domain/src/main/java/kr/co/call/domain/model/home/CallRecord.kt new file mode 100644 index 0000000..c9c16fa --- /dev/null +++ b/core/domain/src/main/java/kr/co/call/domain/model/home/CallRecord.kt @@ -0,0 +1,22 @@ +package kr.co.call.domain.model.home + +import java.time.LocalDateTime + +data class CallInfo( + val callId: Long, + val title: String, + val calledAt: LocalDateTime, + val characterName: String, + val recordingUrl: String?, + val durationMillis: Long, +) + +data class CallTranscript( + val content: String, + val speaker: Speaker, +) { + enum class Speaker { + USER, + AI, + } +} 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 deleted file mode 100644 index dbcf810..0000000 --- a/core/domain/src/main/java/kr/co/call/domain/model/home/CallReservation.kt +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 9b76a00..0000000 --- a/core/domain/src/main/java/kr/co/call/domain/model/home/CallReservations.kt +++ /dev/null @@ -1,6 +0,0 @@ -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/Home.kt b/core/domain/src/main/java/kr/co/call/domain/model/home/Home.kt new file mode 100644 index 0000000..9907160 --- /dev/null +++ b/core/domain/src/main/java/kr/co/call/domain/model/home/Home.kt @@ -0,0 +1,59 @@ +package kr.co.call.domain.model.home + +import java.time.LocalDateTime + +data class HomeSummary( + val firstName: String, + val relationshipDays: Int, + val totalCallCount: Int, + val callStreakDays: Int, +) + +data class HomeCharacter( + val id: Long, + val name: String, + val relationshipDays: Int, + val imageUrl: String?, + val isMain: Boolean, +) + +data class CallReservation( + val id: Long, + val characterId: Long, + val firstName: String, + val imageUrl: String?, + val scheduledAt: LocalDateTime, +) + +data class CallReservations( + val totalCount: Int, + val items: List, +) + +data class CallHistory( + val callId: Long, + val characterName: String, + val aiSummary: String, + val startedAt: LocalDateTime, + val isOutgoing: Boolean, + val isMissed: Boolean, +) + +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/HomeNotification.kt b/core/domain/src/main/java/kr/co/call/domain/model/home/HomeNotification.kt deleted file mode 100644 index 3d9b4cc..0000000 --- a/core/domain/src/main/java/kr/co/call/domain/model/home/HomeNotification.kt +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index b49f33f..0000000 --- a/core/domain/src/main/java/kr/co/call/domain/model/home/HomeOverview.kt +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index ef1d9c1..0000000 --- a/core/domain/src/main/java/kr/co/call/domain/model/home/HomeSummary.kt +++ /dev/null @@ -1,8 +0,0 @@ -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/CallRecordRepository.kt b/core/domain/src/main/java/kr/co/call/domain/repository/CallRecordRepository.kt new file mode 100644 index 0000000..80bcd36 --- /dev/null +++ b/core/domain/src/main/java/kr/co/call/domain/repository/CallRecordRepository.kt @@ -0,0 +1,13 @@ +package kr.co.call.domain.repository + +import kr.co.call.domain.model.home.CallInfo +import kr.co.call.domain.model.home.CallTranscript + +interface CallRecordRepository { + + // 통화 기록 전문 가져오기 + suspend fun getCallInfo(callId: Long): Result + + // 통화 내용 전문 불러오기 + suspend fun getCallScript(callId: Long): Result> +} 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 index b5cf961..35f11f9 100644 --- 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 @@ -2,10 +2,13 @@ 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.HomeCharacter import kr.co.call.domain.model.home.HomeSummary import java.time.LocalDateTime interface HomeRepository { + suspend fun getCharacters(): Result> + suspend fun getReservations(): Result suspend fun getCallHistories(): Result> @@ -16,4 +19,12 @@ interface HomeRepository { reservationId: Long, scheduledAt: LocalDateTime, ): Result + + suspend fun changeMainCharacter( + characterId: Long, + ): Result> + + suspend fun startCall( + characterId: Long, + ): Result } 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 deleted file mode 100644 index 6226d5c..0000000 --- a/core/domain/src/main/java/kr/co/call/domain/usecase/home/HomeUseCase.kt +++ /dev/null @@ -1,22 +0,0 @@ -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/dto/CallTranscriptResponseDto.kt b/core/network/src/main/java/kr/co/call/network/dto/CallTranscriptResponseDto.kt new file mode 100644 index 0000000..09f5dfa --- /dev/null +++ b/core/network/src/main/java/kr/co/call/network/dto/CallTranscriptResponseDto.kt @@ -0,0 +1,17 @@ +package kr.co.call.network.dto + +data class CallTranscriptResponseDto( + val isSuccess: Boolean, + val code: String, + val message: String, + val result: CallTranscriptResultDto, +) + +data class CallTranscriptResultDto( + val content: List, +) + +data class CallTranscriptDto( + val speaker: String, + val content: String, +) diff --git a/feature/home/api/src/main/java/kr/co/call/api/CallRecordRoute.kt b/feature/home/api/src/main/java/kr/co/call/api/CallRecordRoute.kt new file mode 100644 index 0000000..36e30ed --- /dev/null +++ b/feature/home/api/src/main/java/kr/co/call/api/CallRecordRoute.kt @@ -0,0 +1,9 @@ +package kr.co.call.api + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +data class CallRecordNavKey( + val callId: Long, +) : NavKey \ No newline at end of file diff --git a/feature/home/impl/build.gradle.kts b/feature/home/impl/build.gradle.kts index 200826f..47cd6e5 100644 --- a/feature/home/impl/build.gradle.kts +++ b/feature/home/impl/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.core.ktx) implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.paging.compose) // coil @@ -41,4 +42,7 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + + // media3 + implementation(libs.androidx.media3.exoplayer) } 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 deleted file mode 100644 index 44cf327..0000000 --- a/feature/home/impl/src/main/java/kr/co/call/impl/component/CharacterChangeDialog.kt +++ /dev/null @@ -1,11 +0,0 @@ -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/TimeChangeBottomSheet.kt b/feature/home/impl/src/main/java/kr/co/call/impl/component/TimeChangeBottomSheet.kt index eb5dcf4..05d7da7 100644 --- a/feature/home/impl/src/main/java/kr/co/call/impl/component/TimeChangeBottomSheet.kt +++ b/feature/home/impl/src/main/java/kr/co/call/impl/component/TimeChangeBottomSheet.kt @@ -38,9 +38,12 @@ fun TimeChangeBottomSheet( // 월 경계를 포함한 연속 날짜와 30분 단위 시간을 생성 val today = remember { LocalDate.now() } val dates = remember(today) { - (-30L..90L).map { dayOffset -> - today.plusDays(dayOffset) - } + val startDate = today.minusDays(30) + val endDate = today.plusYears(1) + + generateSequence(startDate) { date -> date.plusDays(1) } + .takeWhile { date -> !date.isAfter(endDate) } + .toList() } val times = remember { List(48) { index -> LocalTime.MIN.plusMinutes(index * 30L) } @@ -48,7 +51,7 @@ fun TimeChangeBottomSheet( // 상태의 선택값을 휠 초기 위치로 사용 val initialDateIndex = dates.indexOf(selectedDate).takeIf { index -> index >= 0 } - ?: 30 + ?: dates.indexOf(today) val initialTimeIndex = times.indexOf(selectedTime).takeIf { index -> index >= 0 } ?: 0 val dateListState = rememberLazyListState( initialFirstVisibleItemIndex = initialDateIndex, @@ -148,10 +151,13 @@ private fun TimeWheelPicker( private fun TimeWheelPickerPreview() { CallFromAiTheme { val selectedDate = LocalDate.of(2025, 3, 15) - val dates = remember { - (-30L..90L).map { dayOffset -> - selectedDate.plusDays(dayOffset) - } + val dates = remember(selectedDate) { + val startDate = selectedDate.minusDays(30) + val endDate = selectedDate.plusYears(1) + + generateSequence(startDate) { date -> date.plusDays(1) } + .takeWhile { date -> !date.isAfter(endDate) } + .toList() } val times = remember { List(48) { index -> @@ -162,7 +168,9 @@ private fun TimeWheelPickerPreview() { TimeWheelPicker( dates = dates, times = times, - dateListState = rememberLazyListState(initialFirstVisibleItemIndex = 30), + dateListState = rememberLazyListState( + initialFirstVisibleItemIndex = dates.indexOf(selectedDate), + ), timeListState = rememberLazyListState(initialFirstVisibleItemIndex = 20), onDateSelected = {}, onTimeSelected = {}, diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/component/dialog/CallConnectDialog.kt b/feature/home/impl/src/main/java/kr/co/call/impl/component/dialog/CallConnectDialog.kt new file mode 100644 index 0000000..fe31bd5 --- /dev/null +++ b/feature/home/impl/src/main/java/kr/co/call/impl/component/dialog/CallConnectDialog.kt @@ -0,0 +1,40 @@ +package kr.co.call.impl.component.dialog + +import androidx.compose.foundation.layout.width +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.component.popup.TwoButtonPopup +import kr.co.call.designsystem.theme.CallFromAiTheme + +@Composable +fun CallConnectDialog( + characterName: String, + onConnectClick: () -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + TwoButtonPopup( + label = "통화 연결", + title = "${characterName}에게 바로\n통화를 연결할까요?", + positiveText = "연결", + negativeText = "취소", + onPositiveClick = onConnectClick, + onNegativeClick = onDismissRequest, + onDismissRequest = onDismissRequest, + modifier = modifier.width(321.dp), + ) +} + +@Preview(showBackground = true) +@Composable +private fun CallConnectDialogPreview() { + CallFromAiTheme { + CallConnectDialog( + characterName = "민준", + onConnectClick = {}, + onDismissRequest = {}, + ) + } +} diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/component/dialog/CharacterChangeConfirmDialog.kt b/feature/home/impl/src/main/java/kr/co/call/impl/component/dialog/CharacterChangeConfirmDialog.kt new file mode 100644 index 0000000..e629707 --- /dev/null +++ b/feature/home/impl/src/main/java/kr/co/call/impl/component/dialog/CharacterChangeConfirmDialog.kt @@ -0,0 +1,51 @@ +package kr.co.call.impl.component.dialog + +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +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 kr.co.call.designsystem.component.popup.TwoButtonPopup +import kr.co.call.designsystem.theme.CallFromAiTheme +import kr.co.call.designsystem.theme.CallTheme + +@Composable +fun CharacterChangeConfirmDialog( + characterName: String, + onConfirmClick: () -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + TwoButtonPopup( + label = "메인 연인 교체하기", + title = "${characterName}로 메인을 교체하시겠습니까?", + description = buildAnnotatedString { + append("전화는 선택한 연인과만 가능하며,\n연인 교체 후 ") + withStyle(SpanStyle(color = CallTheme.colors.mainVariant1)) { + append("최소 3일") + } + append("이 지나야\n다시 교체할 수 있습니다.") + }, + positiveText = "확인", + negativeText = "취소", + onPositiveClick = onConfirmClick, + onNegativeClick = onDismissRequest, + onDismissRequest = onDismissRequest, + modifier = modifier.width(321.dp), + ) +} + +@Preview(showBackground = true) +@Composable +private fun CharacterChangeConfirmDialogPreview() { + CallFromAiTheme { + CharacterChangeConfirmDialog( + characterName = "동휘", + onConfirmClick = {}, + onDismissRequest = {}, + ) + } +} diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/component/dialog/CharacterChangeUnavailableDialog.kt b/feature/home/impl/src/main/java/kr/co/call/impl/component/dialog/CharacterChangeUnavailableDialog.kt new file mode 100644 index 0000000..b6b9598 --- /dev/null +++ b/feature/home/impl/src/main/java/kr/co/call/impl/component/dialog/CharacterChangeUnavailableDialog.kt @@ -0,0 +1,55 @@ +package kr.co.call.impl.component.dialog + +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +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 kr.co.call.designsystem.component.popup.OneButtonPopup +import kr.co.call.designsystem.theme.CallFromAiTheme +import kr.co.call.designsystem.theme.CallTheme + +/** + * 캐릭터를 변경할 수 없는 상태를 안내하는 다이얼로그 + * + * @param onConfirmClick 확인 버튼 클릭 콜백 + * @param onDismissRequest 바깥 영역 또는 뒤로가기로 닫을 때 호출되는 콜백 + */ +@Composable +fun CharacterChangeUnavailableDialog( + onConfirmClick: () -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + OneButtonPopup( + label = "캐릭터 변경하기", + title = "아직 캐릭터를\n변경할 수 없어요", + description = buildAnnotatedString { + append("새로운 캐릭터를 만든 후 ") + withStyle( + SpanStyle(color = CallTheme.colors.mainVariant1), + ) { + append("24시간") + } + append("이 지나면\n또 다른 캐릭터를 만들 수 있어요") + }, + buttonText = "확인", + onButtonClick = onConfirmClick, + onDismissRequest = onDismissRequest, + modifier = modifier.width(321.dp), + ) +} + +@Preview(showBackground = true) +@Composable +private fun CharacterChangeUnavailableDialogPreview() { + CallFromAiTheme { + CharacterChangeUnavailableDialog( + onConfirmClick = {}, + onDismissRequest = {}, + ) + } +} diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/component/dialog/NonMainCharacterCallDialog.kt b/feature/home/impl/src/main/java/kr/co/call/impl/component/dialog/NonMainCharacterCallDialog.kt new file mode 100644 index 0000000..e9f9ea5 --- /dev/null +++ b/feature/home/impl/src/main/java/kr/co/call/impl/component/dialog/NonMainCharacterCallDialog.kt @@ -0,0 +1,48 @@ +package kr.co.call.impl.component.dialog + +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kr.co.call.designsystem.component.popup.OneButtonPopup +import kr.co.call.designsystem.theme.CallFromAiTheme + +/** + * 메인 연인이 아닌 캐릭터에게 전화를 걸었을 때 표시되는 다이얼로그 + * @param characterName 전화를 걸려고 했던 캐릭터 이름 + */ +@Composable +fun NonMainCharacterCallDialog( + characterName: String, + onConfirmClick: () -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + OneButtonPopup( + label = "부재중 전화", + title = "앗! 현재는 ${characterName}에게\n전화를 걸 수 없어요. 💦", + description = buildAnnotatedString { + append("전화는 메인 연인과만 이용할 수 있어요.\n") + append("다른 캐릭터와 전화를 하고 싶다면\n") + append("상단에서 연인 교체하기를 클릭해주세요.") + }, + buttonText = "확인", + onButtonClick = onConfirmClick, + onDismissRequest = onDismissRequest, + modifier = modifier.width(321.dp), + ) +} + +@Preview(showBackground = true) +@Composable +private fun NonMainCharacterCallDialogPreview() { + CallFromAiTheme { + NonMainCharacterCallDialog( + characterName = "동휘", + onConfirmClick = {}, + onDismissRequest = {}, + ) + } +} 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 index 786c75c..23c5aba 100644 --- 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 @@ -12,6 +12,7 @@ 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.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -19,6 +20,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -49,6 +51,9 @@ import kr.co.call.impl.viewmodel.model.HomeSummaryUiModel fun HomeHeader( summary: HomeSummaryUiModel, hasUnreadNotification: Boolean, + onCharacterChangeClick: () -> Unit, + onNotificationClick: () -> Unit, + onCallClick: () -> Unit, modifier: Modifier = Modifier, ) { Box( @@ -60,14 +65,15 @@ fun HomeHeader( modifier = Modifier .fillMaxWidth() .height(265.dp) - .background(CallTheme.colors.mainVariant3), + .background(CallTheme.colors.mainVariant3) + .systemBarsPadding(), ) Row( modifier = Modifier .align(Alignment.TopEnd) .padding( - top = 7.dp, + top = 12.dp, end = 3.dp, ), horizontalArrangement = Arrangement.spacedBy(3.dp), @@ -75,7 +81,7 @@ fun HomeHeader( ) { // 메인 연인 교체하기 버튼 Button( - onClick = {}, + onClick = onCharacterChangeClick, modifier = Modifier.size( width = 93.dp, height = 24.dp, @@ -86,7 +92,7 @@ fun HomeHeader( contentColor = CallTheme.colors.gray800, ), contentPadding = PaddingValues( - horizontal = 4.dp, + horizontal = 6.dp, vertical = 3.dp, ), ) { @@ -99,9 +105,9 @@ fun HomeHeader( ) } - Box( + IconButton( + onClick = onNotificationClick, modifier = Modifier.size(48.dp), - contentAlignment = Alignment.Center, ) { Box(modifier = Modifier.size(24.dp)) { // 알림 아이콘 @@ -160,7 +166,7 @@ fun HomeHeader( // 전화하기 버튼 FilledIconButton( - onClick = {}, + onClick = onCallClick, modifier = Modifier .align(Alignment.TopEnd) .padding( @@ -216,6 +222,9 @@ private fun HomeHeaderPreview() { callStreakDaysText = "12일", ), hasUnreadNotification = true, + onCharacterChangeClick = {}, + onNotificationClick = {}, + onCallClick = {}, ) } } 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 index d42b533..d15fc3f 100644 --- 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 @@ -1,7 +1,6 @@ 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 @@ -11,15 +10,19 @@ 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.Icon +import androidx.compose.material3.IconButton 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.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.R import kr.co.call.designsystem.theme.CallFromAiTheme import kr.co.call.designsystem.theme.CallTheme import kr.co.call.impl.mock.CallMockData @@ -122,17 +125,22 @@ private fun CallHistoryCard( // 부재중 통화가 아닐 경우 기록 버튼 표시 if (!isMissed) { - Image( - painter = painterResource( - id = kr.co.call.designsystem.R.drawable.ic_home_record, - ), - contentDescription = "통화 기록 보기", + IconButton( + onClick = onRecordClick, modifier = Modifier .align(Alignment.CenterEnd) .padding(end = 32.dp) - .size(42.dp) - .clickable(onClick = onRecordClick), - ) + .size(42.dp), + ) { + Icon( + painter = painterResource( + id = R.drawable.ic_home_record, + ), + contentDescription = "통화 기록 보기", + modifier = Modifier.size(42.dp), + tint = Color.Unspecified, + ) + } } HorizontalDivider( @@ -153,13 +161,13 @@ private fun CallHistoryIcon( ) { val iconResource = when (iconType) { CallHistoryIconType.SENT -> - kr.co.call.designsystem.R.drawable.img_home_call_send + R.drawable.img_home_call_send CallHistoryIconType.RECEIVED -> - kr.co.call.designsystem.R.drawable.img_home_call_received + R.drawable.img_home_call_received CallHistoryIconType.MISSED -> - kr.co.call.designsystem.R.drawable.img_home_call_missed + R.drawable.img_home_call_missed } val iconSize = when (iconType) { CallHistoryIconType.SENT -> DpSize(26.dp, 23.dp) 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 index 66adaf2..a8298ce 100644 --- 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 @@ -27,9 +27,12 @@ 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.PagingData import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey import java.time.LocalDateTime +import kotlinx.coroutines.flow.Flow import kr.co.call.designsystem.R import kr.co.call.designsystem.theme.CallFromAiTheme import kr.co.call.designsystem.theme.CallTheme @@ -40,6 +43,18 @@ import kr.co.call.impl.component.UnreadIndicator import kr.co.call.impl.mapper.toUiModel import kr.co.call.impl.viewmodel.model.HomeNotificationUiModel +/** + * 알림 Paging 데이터를 Compose 목록 아이템으로 변환 + */ +@Composable +fun NotificationPagingContent( + notifications: Flow>, + content: @Composable (LazyPagingItems) -> Unit, +) { + val lazyNotificationItems = notifications.collectAsLazyPagingItems() + content(lazyNotificationItems) +} + /** * 알림 리스트 안내 문구 */ @@ -64,6 +79,7 @@ fun NotificationListHeader( */ fun LazyListScope.notificationItems( notifications: LazyPagingItems, + onCallClick: (characterName: String) -> Unit, ) { items( count = notifications.itemCount, @@ -72,7 +88,9 @@ fun LazyListScope.notificationItems( notifications[index]?.let { notification -> NotificationCard( notificationState = notification.toUiModel(), - onCallClick = {}, + onCallClick = { + notification.characterName?.let(onCallClick) + }, modifier = Modifier.padding( start = 16.dp, end = 16.dp, diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/component/record/CallRecordHeader.kt b/feature/home/impl/src/main/java/kr/co/call/impl/component/record/CallRecordHeader.kt new file mode 100644 index 0000000..0ac1820 --- /dev/null +++ b/feature/home/impl/src/main/java/kr/co/call/impl/component/record/CallRecordHeader.kt @@ -0,0 +1,121 @@ +package kr.co.call.impl.component.record + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +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 kr.co.call.designsystem.R +import kr.co.call.designsystem.theme.CallFromAiTheme +import kr.co.call.designsystem.theme.CallTheme +import kr.co.call.impl.viewmodel.model.CallRecordUiModel + +/** + * 통화 기록 화면의 뒤로가기와 제목을 표시하는 상단 영역 + */ +@Composable +fun CallRecordTopBar( + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .statusBarsPadding() + .height(52.dp) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = onBackClick, + modifier = Modifier.size(40.dp), + ) { + Image( + painter = painterResource(R.drawable.ic_mypage_arrow_left), + contentDescription = "뒤로가기", + modifier = Modifier.size(width = 10.dp, height = 18.dp), + ) + } + + Text( + text = "기록", + color = CallTheme.colors.black, + style = CallTheme.typography.bodyLargeBold, + ) + } +} + +/** + * 조회한 통화 기록의 제목과 통화 정보를 표시하는 영역 + */ +@Composable +fun CallRecordSummary( + record: CallRecordUiModel, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Spacer(modifier = Modifier.height(21.dp)) + Text( + text = record.title, + color = CallTheme.colors.black, + style = CallTheme.typography.titleSmallBold, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = record.calledAtText, + color = CallTheme.colors.gray600, + style = CallTheme.typography.bodySmall, + ) + Spacer(modifier = Modifier.height(3.dp)) + Text( + text = "${record.characterName}님과의 통화", + color = CallTheme.colors.gray600, + style = CallTheme.typography.bodySmall, + ) + Spacer(modifier = Modifier.height(14.dp)) + HorizontalDivider( + color = CallTheme.colors.gray100, + ) + } +} + +@Preview(showBackground = true, widthDp = 412) +@Composable +private fun CallRecordTopBarPreview() { + CallFromAiTheme { + CallRecordTopBar( + onBackClick = {}, + ) + } +} + +@Preview(showBackground = true, widthDp = 412) +@Composable +private fun CallRecordSummaryPreview() { + CallFromAiTheme { + CallRecordSummary( + record = CallRecordUiModel( + title = "출근 준비와 아침 일정 이야기", + calledAtText = "6월 27일 오전 7시 31분", + characterName = "민준", + ), + ) + } +} diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/component/record/CallRecordLoadingContent.kt b/feature/home/impl/src/main/java/kr/co/call/impl/component/record/CallRecordLoadingContent.kt new file mode 100644 index 0000000..24cee0b --- /dev/null +++ b/feature/home/impl/src/main/java/kr/co/call/impl/component/record/CallRecordLoadingContent.kt @@ -0,0 +1,36 @@ +package kr.co.call.impl.component.record + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import kr.co.call.designsystem.theme.CallFromAiTheme +import kr.co.call.designsystem.theme.CallTheme + +/** + * 통화 기록을 불러오는 동안 표시하는 로딩 영역 + */ +@Composable +fun CallRecordLoadingContent( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = CallTheme.colors.mainVariant1, + ) + } +} + +@Preview(showBackground = true, widthDp = 412, heightDp = 600) +@Composable +private fun CallRecordLoadingContentPreview() { + CallFromAiTheme { + CallRecordLoadingContent() + } +} diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/component/record/CallRecordPlayer.kt b/feature/home/impl/src/main/java/kr/co/call/impl/component/record/CallRecordPlayer.kt new file mode 100644 index 0000000..7b24b00 --- /dev/null +++ b/feature/home/impl/src/main/java/kr/co/call/impl/component/record/CallRecordPlayer.kt @@ -0,0 +1,239 @@ +package kr.co.call.impl.component.record + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kr.co.call.designsystem.R +import kr.co.call.designsystem.theme.CallFromAiTheme +import kr.co.call.designsystem.theme.CallTheme + +/** + * 녹음 진행 상태와 5초 이동, 재생 동작을 제공하는 고정 하단 영역 + */ +@Composable +fun CallRecordPlayer( + state: CallRecordPlayerState, + onSeek: (Long) -> Unit, + onRewindClick: () -> Unit, + onPlayPauseClick: () -> Unit, + onForwardClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val progress = if (state.durationMillis > 0L) { + state.currentPositionMillis.toFloat() / state.durationMillis + } else { + 0f + }.coerceIn(0f, 1f) + + Column( + modifier = modifier + .fillMaxWidth() + .height(108.dp) + .padding(horizontal = 16.dp), + ) { + CallRecordProgressBar( + progress = progress, + onProgressChange = { changedProgress -> + onSeek((state.durationMillis * changedProgress).toLong()) + }, + ) + Spacer(modifier = Modifier.height(3.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + PlayerTimeText( + text = state.currentPositionMillis.toPlayerTime(), + isCurrent = true, + ) + PlayerTimeText( + text = state.durationMillis.toPlayerTime(), + isCurrent = false, + ) + } + Spacer(modifier = Modifier.height(13.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + space = 42.dp, + alignment = Alignment.CenterHorizontally, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + PlayerSeekButton( + drawableRes = R.drawable.ic_home_call_replay_5, + contentDescription = "5초 전", + onClick = onRewindClick, + ) + PlayPauseButton( + isPlaying = state.isPlaying, + onClick = onPlayPauseClick, + ) + PlayerSeekButton( + drawableRes = R.drawable.ic_home_call_replay_5_after, + contentDescription = "5초 후", + onClick = onForwardClick, + ) + } + } +} + +/** + * 녹음 진행 상태를 표시하는 Progress Bar + */ +@Composable +private fun CallRecordProgressBar( + progress: Float, + onProgressChange: (Float) -> Unit, + modifier: Modifier = Modifier, +) { + val inactiveColor = CallTheme.colors.gray100 + val activeColor = CallTheme.colors.mainVariant1 + + Canvas( + modifier = modifier + .fillMaxWidth() + .height(12.dp) + .pointerInput(onProgressChange) { + detectTapGestures { offset -> + onProgressChange((offset.x / size.width).coerceIn(0f, 1f)) + } + }, + ) { + val centerY = size.height / 2f + val progressX = size.width * progress + + drawLine( + color = inactiveColor, + start = Offset(0f, centerY), + end = Offset(size.width, centerY), + strokeWidth = 4.dp.toPx(), + cap = StrokeCap.Round, + ) + drawLine( + color = activeColor, + start = Offset(0f, centerY), + end = Offset(progressX, centerY), + strokeWidth = 4.dp.toPx(), + cap = StrokeCap.Round, + ) + drawCircle( + color = activeColor, + radius = 4.dp.toPx(), + center = Offset(progressX, centerY), + ) + } +} + +/** + * 재생 시간 텍스트 + */ +@Composable +private fun PlayerTimeText( + text: String, + isCurrent: Boolean, +) { + Text( + text = text, + color = if (isCurrent) { + CallTheme.colors.mainVariant1 + } else { + CallTheme.colors.gray400 + }, + style = CallTheme.typography.captionBold, + ) +} + +/** + * 녹음 파일을 5초 이전 또는 이후로 이동하는 버튼 + */ +@Composable +private fun PlayerSeekButton( + drawableRes: Int, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + IconButton( + onClick = onClick, + modifier = modifier.size(48.dp), + ) { + Icon( + painter = painterResource(drawableRes), + contentDescription = contentDescription, + modifier = Modifier.size(24.dp), + ) + } +} + +/** + * 현재 재생 상태에 따라 재생 또는 일시정지 동작을 제공하는 버튼 + */ +@Composable +private fun PlayPauseButton( + isPlaying: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + IconButton( + onClick = onClick, + modifier = modifier.size(48.dp), + ) { + Icon( + painter = painterResource( + if (isPlaying) { + R.drawable.ic_home_call_pause + } else { + R.drawable.ic_home_call_play + }, + ), + contentDescription = if (isPlaying) "일시정지" else "재생", + modifier = Modifier.size(34.dp), + ) + } +} + +// String -> 재생 시간으로 변환하는 확장함수 +private fun Long.toPlayerTime(): String { + val totalSeconds = (this / 1_000L).coerceAtLeast(0L) + val minutes = totalSeconds / 60L + val seconds = totalSeconds % 60L + return "%02d:%02d".format(minutes, seconds) +} + + +@Preview(showBackground = true, widthDp = 412) +@Composable +private fun CallRecordPlayerPreview() { + CallFromAiTheme { + CallRecordPlayer( + state = CallRecordPlayerState( + currentPositionMillis = 31_000L, + durationMillis = 81_000L, + ), + onSeek = {}, + onRewindClick = {}, + onPlayPauseClick = {}, + onForwardClick = {}, + ) + } +} diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/component/record/CallRecordPlayerState.kt b/feature/home/impl/src/main/java/kr/co/call/impl/component/record/CallRecordPlayerState.kt new file mode 100644 index 0000000..abb689f --- /dev/null +++ b/feature/home/impl/src/main/java/kr/co/call/impl/component/record/CallRecordPlayerState.kt @@ -0,0 +1,11 @@ +package kr.co.call.impl.component.record + +/** + * 통화 녹음 플레이어에 표시되는 재생 상태 + * + */ +data class CallRecordPlayerState( + val currentPositionMillis: Long = 0L, + val durationMillis: Long = 0L, + val isPlaying: Boolean = false, +) diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/component/record/CallTranscriptList.kt b/feature/home/impl/src/main/java/kr/co/call/impl/component/record/CallTranscriptList.kt new file mode 100644 index 0000000..a635353 --- /dev/null +++ b/feature/home/impl/src/main/java/kr/co/call/impl/component/record/CallTranscriptList.kt @@ -0,0 +1,147 @@ +package kr.co.call.impl.component.record + +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.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.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.domain.model.home.CallTranscript + +/** + * 통화 내용을 말풍선 목록으로 표시 + */ +@Composable +fun CallTranscriptList( + transcripts: List, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues( + start = 16.dp, + top = 16.dp, + end = 16.dp, + bottom = 20.dp, + ), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // 타이틀 + item(key = "recording_content_title") { + Text( + text = "녹음 내용", + color = CallTheme.colors.gray800, + style = CallTheme.typography.bodySmallBold, + ) + } + + items(items = transcripts) { transcript -> + CallTranscriptBubble(transcript = transcript) + } + } +} + +@Composable +private fun CallTranscriptBubble( + transcript: CallTranscript, + modifier: Modifier = Modifier, +) { + val isUser = transcript.speaker == CallTranscript.Speaker.USER + val bubbleShape = RoundedCornerShape(20.dp) + + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart, + ) { + Text( + text = transcript.content, + color = CallTheme.colors.black, + style = CallTheme.typography.bodyMedium, + modifier = Modifier + .widthIn(max = 340.dp) + .then( + if (isUser) { + Modifier.background( + color = CallTheme.colors.mainVariant5Chat, + shape = bubbleShape, + ) + } else { + Modifier.border( + width = 1.3.dp, + color = CallTheme.colors.gray100, + shape = bubbleShape, + ) + }, + ) + .padding(horizontal = 15.dp, vertical = 10.dp), + ) + } +} + +@Preview(showBackground = true, widthDp = 412, heightDp = 500) +@Composable +private fun CallTranscriptListPreview() { + CallFromAiTheme { + CallTranscriptList( + transcripts = listOf( + CallTranscript( + content = "여보세요", + speaker = CallTranscript.Speaker.USER, + ), + CallTranscript( + content = "잘 일어났어? 목소리 아직 잠긴 것 같은데.", + speaker = CallTranscript.Speaker.AI, + ), + CallTranscript( + content = "응 방금 일어나 준비하고 있었어", + speaker = CallTranscript.Speaker.USER, + ), + CallTranscript( + content = "오늘 출근이지? 몸은 좀 괜찮아? 피곤해 보여.", + speaker = CallTranscript.Speaker.AI, + ), + ), + ) + } +} + +@Preview(showBackground = true, widthDp = 412) +@Composable +private fun UserCallTranscriptBubblePreview() { + CallFromAiTheme { + CallTranscriptBubble( + transcript = CallTranscript( + content = "응 방금 일어나 준비하고 있었어", + speaker = CallTranscript.Speaker.USER, + ), + modifier = Modifier.padding(16.dp), + ) + } +} + +@Preview(showBackground = true, widthDp = 412) +@Composable +private fun CharacterCallTranscriptBubblePreview() { + CallFromAiTheme { + CallTranscriptBubble( + transcript = CallTranscript( + content = "잘 일어났어? 목소리 아직 잠긴 것 같은데.", + speaker = CallTranscript.Speaker.AI, + ), + modifier = Modifier.padding(16.dp), + ) + } +} diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/entry/HomeEntryBuilder.kt b/feature/home/impl/src/main/java/kr/co/call/impl/entry/HomeEntryBuilder.kt index 558c4a4..6fbb7b1 100644 --- a/feature/home/impl/src/main/java/kr/co/call/impl/entry/HomeEntryBuilder.kt +++ b/feature/home/impl/src/main/java/kr/co/call/impl/entry/HomeEntryBuilder.kt @@ -2,11 +2,24 @@ package kr.co.call.impl.entry import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey +import kr.co.call.api.CallRecordNavKey import kr.co.call.api.HomeNavKey +import kr.co.call.impl.screen.CallRecordScreen import kr.co.call.impl.screen.HomeScreen -fun EntryProviderScope.homeEntry() { +fun EntryProviderScope.homeEntry( + navigateToCallRecord: (Long) -> Unit, + onCallRecordBack: () -> Unit, +) { entry { - HomeScreen() + HomeScreen( + onNavigateToCallRecord = navigateToCallRecord, + ) } -} \ No newline at end of file + entry { key -> + CallRecordScreen( + callId = key.callId, + onBackClick = onCallRecordBack, + ) + } +} diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/mapper/CallRecordUiMapper.kt b/feature/home/impl/src/main/java/kr/co/call/impl/mapper/CallRecordUiMapper.kt new file mode 100644 index 0000000..fbf1b1c --- /dev/null +++ b/feature/home/impl/src/main/java/kr/co/call/impl/mapper/CallRecordUiMapper.kt @@ -0,0 +1,13 @@ +package kr.co.call.impl.mapper + +import kr.co.call.core.common.util.TimeUtil +import kr.co.call.domain.model.home.CallInfo +import kr.co.call.impl.viewmodel.model.CallRecordUiModel + +fun CallInfo.toUiModel(): CallRecordUiModel = + CallRecordUiModel( + title = title, + calledAtText = TimeUtil.toCallHistoryDateText(calledAt), + characterName = characterName, + recordingUrl = recordingUrl, + ) 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 index b62d395..b75662e 100644 --- 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 @@ -2,7 +2,9 @@ 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.HomeCharacter import kr.co.call.domain.model.home.HomeSummary +import kr.co.call.impl.viewmodel.model.CharacterOptionUiModel import kr.co.call.impl.viewmodel.model.HomeReservationUiModel import kr.co.call.impl.viewmodel.model.HomeSummaryUiModel @@ -18,6 +20,7 @@ fun CallReservations.toUiModel(): HomeReservationUiModel { val reservation = items.firstOrNull() return HomeReservationUiModel( + reservationId = reservation?.id, hasReservation = reservation != null, reservationCountText = "약속 ${totalCount}건", profileImageUrl = reservation?.imageUrl, @@ -27,5 +30,14 @@ fun CallReservations.toUiModel(): HomeReservationUiModel { ) } +fun HomeCharacter.toUiModel(): CharacterOptionUiModel = + CharacterOptionUiModel( + characterId = id, + name = name, + day = relationshipDays, + imageUrl = imageUrl, + isSelected = isMain, + ) + 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/CharacterMockData.kt b/feature/home/impl/src/main/java/kr/co/call/impl/mock/CharacterMockData.kt deleted file mode 100644 index 61bed57..0000000 --- a/feature/home/impl/src/main/java/kr/co/call/impl/mock/CharacterMockData.kt +++ /dev/null @@ -1,10 +0,0 @@ -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/CallRecordScreen.kt b/feature/home/impl/src/main/java/kr/co/call/impl/screen/CallRecordScreen.kt new file mode 100644 index 0000000..9b2db2d --- /dev/null +++ b/feature/home/impl/src/main/java/kr/co/call/impl/screen/CallRecordScreen.kt @@ -0,0 +1,379 @@ +package kr.co.call.impl.screen + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Spacer +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.lifecycle.compose.LifecycleStartEffect +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kr.co.call.designsystem.theme.CallFromAiTheme +import kr.co.call.designsystem.theme.CallTheme +import kr.co.call.domain.model.home.CallTranscript +import kr.co.call.domain.util.LoadStatus +import kr.co.call.impl.component.record.CallRecordLoadingContent +import kr.co.call.impl.component.record.CallRecordPlayer +import kr.co.call.impl.component.record.CallRecordPlayerState +import kr.co.call.impl.component.record.CallRecordSummary +import kr.co.call.impl.component.record.CallRecordTopBar +import kr.co.call.impl.component.record.CallTranscriptList +import kr.co.call.impl.viewmodel.CallRecordIntent +import kr.co.call.impl.viewmodel.CallRecordSideEffect +import kr.co.call.impl.viewmodel.CallRecordViewModel +import kr.co.call.impl.viewmodel.model.CallRecordUiModel +import kr.co.call.impl.viewmodel.state.CallRecordState +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect + +/** + * 통화 정보와 대화 내용, 고정 재생 바 표시하는 화면 + */ +@Composable +fun CallRecordScreen( + modifier: Modifier = Modifier, + callId: Long, + onBackClick: () -> Unit, + viewModel: CallRecordViewModel = hiltViewModel(), +) { + val state by viewModel.collectAsState() + val context = LocalContext.current + val recordingUrl = state.record.recordingUrl + + // 같은 녹음 파일은 화면이 재생성되어도 마지막 재생 위치를 복원 + var savedPositionMillis by rememberSaveable(recordingUrl) { + mutableLongStateOf(0L) + } + + val audioAttributes = AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_SPEECH) + .build() + + // ExoPlayer + val player = remember { + ExoPlayer.Builder(context.applicationContext) + .setAudioAttributes( + audioAttributes, + true, // 오디오 포커스 자동 관리 + ) + .setHandleAudioBecomingNoisy(true) + .build() + .apply { + volume = 1f + } + } + + // 앱이 백그라운드로 이동하면 현재 위치를 유지한 채 재생만 일시정지 + LifecycleStartEffect(player) { + onStopOrDispose { + player.pause() + } + } + + // 플레이어 상태 + var playerState by remember(recordingUrl) { + mutableStateOf( + CallRecordPlayerState( + currentPositionMillis = savedPositionMillis, + ), + ) + } + + // 녹음 파일에서 선택한 위치로 이동하는 함수 + fun seekTo(positionMillis: Long) { + // 최대 위치 + val maximumPosition = playerState.durationMillis + .takeIf { it > 0L} + ?: Long.MAX_VALUE + + // 재생 범위 제한 + val targetPosition = positionMillis.coerceIn( + minimumValue = 0L, + maximumValue = maximumPosition, + ) + + // 현재 재생중인 위치로 이동 + player.seekTo(targetPosition) + + // 상태값에 현재 위치 저장 + playerState = playerState.copy( + currentPositionMillis = targetPosition, + ) + savedPositionMillis = targetPosition + } + + LaunchedEffect(callId) { + viewModel.handleIntent( + CallRecordIntent.Load(callId), + ) + } + + // play와 url을 가져와서 재생 준비 처리 + LaunchedEffect( + player, + recordingUrl, + ) { + playerState = CallRecordPlayerState( + currentPositionMillis = savedPositionMillis, + ) + + if (recordingUrl.isNullOrBlank()) { + player.clearMediaItems() + return@LaunchedEffect + } + player.setMediaItem( + MediaItem.fromUri(recordingUrl), + savedPositionMillis, + ) + // 재생 준비 + player.prepare() + } + + LaunchedEffect( + player, + playerState.isPlaying, + ) { + while ( + isActive && playerState.isPlaying + ) { + val currentPositionMillis = player.currentPosition.coerceAtLeast(0L) + playerState = playerState.copy( + currentPositionMillis = currentPositionMillis, + ) + savedPositionMillis = currentPositionMillis + delay(250L) + } + } + + + DisposableEffect(player) { + val listener = object : Player.Listener { + // 재생 상태가 바뀌면 재생 여부를 전달 + override fun onIsPlayingChanged(isPlaying: Boolean){ + playerState = playerState.copy( + isPlaying = isPlaying, + ) + } + // 플레이어가 준비 상태일 때 전체 음성 길이 가져옴 + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_READY -> { + val durationMillis = + if ( + player.duration != C.TIME_UNSET && + player.duration > 0L + ) { + player.duration + } else { + 0L + } + + playerState = playerState.copy( + durationMillis = durationMillis, + ) + } + + Player.STATE_ENDED -> { + savedPositionMillis = playerState.durationMillis + playerState = playerState.copy( + currentPositionMillis = + playerState.durationMillis, + isPlaying = false, + ) + } + } + + } + + override fun onPlayerError(error: PlaybackException) { + playerState = playerState.copy( + isPlaying = false, + ) + Toast.makeText( + context, + "녹음 파일을 재생하지 못했습니다.", + Toast.LENGTH_SHORT, + ).show() + } + + } + // listener를 ExoPlayer에 등록 + player.addListener(listener) + + onDispose { + player.removeListener(listener) + player.release() + } + } + + viewModel.collectSideEffect { sideEffect -> + when (sideEffect) { + is CallRecordSideEffect.ShowMessage -> { + Toast.makeText( + context, + sideEffect.message, + Toast.LENGTH_SHORT, + ).show() + } + } + } + + CallRecordScreenContent( + state = state, + playerState = playerState, + onBackClick = onBackClick, + onSeek = ::seekTo, + // 5초 전으로 이동 + onRewindClick = { + seekTo( + player.currentPosition - 5_000L, + ) + }, + // 재생,정지버튼 + onPlayPauseClick = { + if (player.isPlaying) { + player.pause() + } else { + // 재생 종료되었다면 0으로 초기화 + if (player.playbackState == Player.STATE_ENDED) { + seekTo(0L) + } + player.play() + } + }, + // 5초 후로 이동 + onForwardClick = { + seekTo( + player.currentPosition + 5_000L, + ) + }, + modifier = modifier, + ) +} + + + +@Composable +fun CallRecordScreenContent( + state: CallRecordState, + playerState: CallRecordPlayerState, + onBackClick: () -> Unit, + onSeek: (Long) -> Unit, + onRewindClick: () -> Unit, + onPlayPauseClick: () -> Unit, + onForwardClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .background(CallTheme.colors.white), + ) { + CallRecordTopBar( + onBackClick = onBackClick, + ) + + when (state.loadStatus) { + LoadStatus.Loading -> { + CallRecordLoadingContent( + modifier = Modifier.weight(1f), + ) + } + + is LoadStatus.Error -> { + Spacer( + modifier = Modifier.weight(1f), + ) + } + + LoadStatus.Idle -> { + CallRecordSummary( + record = state.record, + ) + + CallTranscriptList( + transcripts = state.transcripts, + modifier = Modifier.weight(1f), + ) + + CallRecordPlayer( + state = playerState, + onSeek = onSeek, + onRewindClick = onRewindClick, + onPlayPauseClick = onPlayPauseClick, + onForwardClick = onForwardClick, + ) + } + } + } +} + +@Preview(showBackground = true, widthDp = 412, heightDp = 917) +@Composable +private fun CallRecordScreenPreview() { + CallFromAiTheme { + CallRecordScreenContent( + state = CallRecordState( + record = CallRecordUiModel( + title = "출근 준비와 아침 일정 이야기", + calledAtText = "6월 27일 오전 7시 31분", + characterName = "민준", + ), + transcripts = listOf( + "여보세요", + "잘 일어났어? 목소리 아직 잠긴 것 같은데.", + "응 방금 일어나 준비하고 있었어", + "오늘 출근이지? 몸은 좀 괜찮아? 피곤해 보여.", + "어제 조금 늦게 자서 그런가 봐. 그래도 괜찮아", + "다행이다. 너무 무리하지 말고 천천히 준비해.", + "응 이제 씻고 나가려고", + "오늘 오전 일정은 어떻게 돼?", + "11시에 회의 하나 있고, 그거 끝나면 바로 외부 미팅이 있어.", + "점심은?", + "팀원들이랑 먹기로 했어.", + "뭐 먹을지 정했어?", + "아직. 근처 가서 먹으려고.", + ).mapIndexed { index, content -> + CallTranscript( + content = content, + speaker = if (index % 2 == 0) { + CallTranscript.Speaker.USER + } else { + CallTranscript.Speaker.AI + }, + ) + }, + loadStatus = LoadStatus.Idle, + ), + playerState = CallRecordPlayerState( + currentPositionMillis = 31_000L, + durationMillis = 81_000L, + ), + onBackClick = {}, + onSeek = {}, + onRewindClick = {}, + onPlayPauseClick = {}, + onForwardClick = {}, + ) + } +} 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 525d544..ad461a0 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,5 +1,6 @@ package kr.co.call.impl.screen +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -12,75 +13,119 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext 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 androidx.paging.compose.LazyPagingItems 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.TimeChangeBottomSheet +import kr.co.call.impl.component.dialog.CallConnectDialog +import kr.co.call.impl.component.dialog.CharacterChangeConfirmDialog +import kr.co.call.impl.component.dialog.CharacterChangeDialog +import kr.co.call.impl.component.dialog.CharacterChangeUnavailableDialog +import kr.co.call.impl.component.dialog.NonMainCharacterCallDialog 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.NotificationPagingContent 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.HomeSideEffect 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 kr.co.call.impl.viewmodel.state.HomeDialogState +import kr.co.call.impl.viewmodel.state.HomeState import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect /** - * 홈 화면 진입 컴포넌트 - * - ViewModel 상태 구독 - * - 헤더와 약속 상태를 화면 콘텐츠에 전달 + * 홈 화면 진입점 + * - ViewModel 상태와 SideEffect 구독 + * - 화면 이동 이벤트를 상위 내비게이터로 전달 */ @Composable fun HomeScreen( modifier: Modifier = Modifier, + onNavigateToCharacterOnboarding: () -> Unit = {}, + onNavigateToCall: () -> Unit = {}, + onNavigateToCallRecord: (Long) -> Unit = {}, viewModel: HomeViewModel = hiltViewModel(), ) { val state by viewModel.collectAsState() val notifications = remember { createHomeMockData().notificationFlow() } + val context = LocalContext.current + + viewModel.collectSideEffect { sideEffect -> + when (sideEffect) { + HomeSideEffect.NavigateToCharacterOnboarding -> { + onNavigateToCharacterOnboarding() + } + + HomeSideEffect.NavigateToCall -> { + onNavigateToCall() + } + + is HomeSideEffect.NavigateToCallRecord -> { + onNavigateToCallRecord(sideEffect.callId) + } + + is HomeSideEffect.ShowMessage -> { + Toast.makeText(context, sideEffect.message, Toast.LENGTH_SHORT).show() + } + } + } HomeScreenContent( - homeSummary = state.summary, - hasUnreadNotification = state.hasUnreadNotification, - homeReservation = state.reservation, - callHistories = state.callHistories, - selectedHistoryTab = state.selectedHistoryTab, + state = state, notifications = notifications, - onHistoryTabClick = { tab -> - viewModel.handleIntent(HomeIntent.SelectHistoryTab(tab)) - }, + onIntent = viewModel::handleIntent, modifier = modifier, ) } /** - * 홈 화면 콘텐츠 컴포넌트 - * - 홈 헤더와 약속 배너 배치 - * - 배경과 영역 구분선 표시 + * 홈 화면 UI + * - 상태를 화면에 표시 + * - 사용자 행동을 Intent로 전달 */ @Composable -fun HomeScreenContent( - homeSummary: HomeSummaryUiModel, - hasUnreadNotification: Boolean, - homeReservation: HomeReservationUiModel, - callHistories: List, - selectedHistoryTab: HomeHistoryTab, +private fun HomeScreenContent( + state: HomeState, notifications: Flow>, - onHistoryTabClick: (HomeHistoryTab) -> Unit, + onIntent: (HomeIntent) -> Unit, modifier: Modifier = Modifier, ) { - val lazyNotificationItems = notifications.collectAsLazyPagingItems() + NotificationPagingContent(notifications = notifications) { lazyNotifications -> + HomeContent( + state = state, + notifications = lazyNotifications, + onIntent = onIntent, + modifier = modifier, + ) + } + HomeDialogs( + state = state, + onIntent = onIntent, + ) +} + +@Composable +private fun + HomeContent( + state: HomeState, + notifications: LazyPagingItems, + onIntent: (HomeIntent) -> Unit, + modifier: Modifier = Modifier, +) { LazyColumn( modifier = modifier .fillMaxSize() @@ -88,14 +133,27 @@ fun HomeScreenContent( ) { item { HomeHeader( - summary = homeSummary, - hasUnreadNotification = hasUnreadNotification, + summary = state.summary, + hasUnreadNotification = state.hasUnreadNotification, + onCharacterChangeClick = { + onIntent(HomeIntent.Character.ClickChange) + }, + onNotificationClick = { + onIntent(HomeIntent.History.ClickNotificationShortcut) + }, + onCallClick = { + onIntent(HomeIntent.Call.ClickMain) + }, ) } item { - // 예약된 전화 조회 - HomeReservationCard(reservation = homeReservation) + HomeReservationCard( + reservation = state.reservation, + onTimeChangeClick = { + onIntent(HomeIntent.TimeChange.Click) + }, + ) } item { @@ -108,25 +166,35 @@ fun HomeScreenContent( } item { - // 지난 알림, 통화 기록 표시 섹션 HomeHistorySection( - selectedTab = selectedHistoryTab, - onTabClick = onHistoryTabClick, + selectedTab = state.selectedHistoryTab, + onTabClick = { tab -> + onIntent(HomeIntent.History.SelectTab(tab)) + }, ) } - - // 탭 선택 상태에 따라서 표시 - when (selectedHistoryTab) { + + when (state.selectedHistoryTab) { HomeHistoryTab.NOTIFICATION -> { item { NotificationListHeader() } - notificationItems(lazyNotificationItems) + notificationItems( + notifications = notifications, + onCallClick = { characterName -> + onIntent(HomeIntent.Call.ClickNotification(characterName)) + }, + ) } HomeHistoryTab.CALL_HISTORY -> { item { - CallHistoryList(histories = callHistories) + CallHistoryList( + histories = state.callHistories, + onRecordClick = { callId -> + onIntent(HomeIntent.History.ClickRecord(callId)) + }, + ) } } } @@ -134,29 +202,140 @@ fun HomeScreenContent( } /** - * 홈 화면 Preview + * 다이얼로그를 표시하는 UI */ +@Composable +private fun HomeDialogs( + state: HomeState, + onIntent: (HomeIntent) -> Unit, +) { + when (val dialogState = state.dialogState) { + HomeDialogState.CharacterSelection -> { + CharacterChangeDialog( + characters = state.characters, + onCharacterClick = { characterId, characterName -> + onIntent( + // 선택한 캐릭터로 변경 + HomeIntent.Character.Select( + characterId = characterId, + characterName = characterName, + ), + ) + }, + onAddCharacterClick = { + // 캐릭터 선택 + onIntent(HomeIntent.Character.ClickAdd) + }, + onDismissRequest = { + // 취소 버튼 클릭 + onIntent(HomeIntent.DismissDialog) + }, + ) + } + + HomeDialogState.CharacterChangeUnavailable -> { + CharacterChangeUnavailableDialog( + onConfirmClick = { + onIntent(HomeIntent.DismissDialog) + }, + onDismissRequest = { + onIntent(HomeIntent.DismissDialog) + }, + ) + } + + // 캐릭터 변경 팝업일 경우 + is HomeDialogState.CharacterChangeConfirmation -> { + CharacterChangeConfirmDialog( + characterName = dialogState.characterName, + // 확인 버튼 클릭 + onConfirmClick = { + onIntent(HomeIntent.Character.ConfirmChange) + }, + // 취소 버튼 클릭 + onDismissRequest = { + onIntent(HomeIntent.DismissDialog) + }, + ) + } + + // 시간 변경 팝업일 경우 + is HomeDialogState.TimeChange -> { + TimeChangeBottomSheet( + selectedDate = dialogState.state.selectedDate, + selectedTime = dialogState.state.selectedTime, + onDateSelected = { date -> + onIntent(HomeIntent.TimeChange.SelectDate(date)) + }, + onTimeSelected = { time -> + onIntent(HomeIntent.TimeChange.SelectTime(time)) + }, + onConfirmClick = { + onIntent(HomeIntent.TimeChange.Confirm) + }, + onDismissRequest = { + onIntent(HomeIntent.DismissDialog) + }, + ) + } + + is HomeDialogState.CallConfirmation -> { + CallConnectDialog( + characterName = dialogState.characterName, + onConnectClick = { + onIntent(HomeIntent.Call.Confirm) + }, + onDismissRequest = { + onIntent(HomeIntent.DismissDialog) + }, + ) + } + + is HomeDialogState.NonMainCharacterCall -> { + NonMainCharacterCallDialog( + characterName = dialogState.characterName, + onConfirmClick = { + onIntent(HomeIntent.DismissDialog) + }, + onDismissRequest = { + onIntent(HomeIntent.DismissDialog) + }, + ) + } + + null -> Unit + } +} + @Preview(showBackground = true, widthDp = 412) @Composable -private fun HomeScreenContentPreview() { +private fun HomeScreenPreview() { val mockData = remember { createHomeMockData( now = LocalDateTime.of(2026, 7, 17, 15, 0), ) } - var selectedHistoryTab by remember { - mutableStateOf(HomeHistoryTab.CALL_HISTORY) + var state by remember { + mutableStateOf( + HomeState( + summary = mockData.summaryUiModel, + reservation = mockData.reservationUiModel, + callHistories = mockData.callHistoryUiModels, + hasUnreadNotification = mockData.hasUnreadNotification, + selectedHistoryTab = HomeHistoryTab.CALL_HISTORY, + ), + ) } CallFromAiTheme { HomeScreenContent( - homeSummary = mockData.summaryUiModel, - hasUnreadNotification = mockData.hasUnreadNotification, - homeReservation = mockData.reservationUiModel, - callHistories = mockData.callHistoryUiModels, - selectedHistoryTab = selectedHistoryTab, + state = state, notifications = mockData.notificationFlow(), - onHistoryTabClick = { selectedHistoryTab = it }, + onIntent = { intent -> + if (intent is HomeIntent.History.SelectTab) { + state = state.copy(selectedHistoryTab = intent.tab) + } + }, ) } } diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/util/DateTimeRounding.kt b/feature/home/impl/src/main/java/kr/co/call/impl/util/DateTimeRounding.kt new file mode 100644 index 0000000..ffd07b2 --- /dev/null +++ b/feature/home/impl/src/main/java/kr/co/call/impl/util/DateTimeRounding.kt @@ -0,0 +1,42 @@ +package kr.co.call.impl.util + +import java.time.LocalDateTime + +/** + * 시간을 분 단위로 반올림하는 Util + */ +fun LocalDateTime.roundToMinutes( + intervalMinutes: Int, + direction: TimeRoundDirection, +): LocalDateTime { + require(intervalMinutes > 0) { + "시간 간격은 0보다 커야 합니다." + } + + val minuteBase = withSecond(0).withNano(0) + val minuteOfDay = minuteBase.hour * 60 + minuteBase.minute + val remainder = minuteOfDay % intervalMinutes + + return when (direction) { + TimeRoundDirection.DOWN -> { + minuteBase.minusMinutes(remainder.toLong()) + } + + TimeRoundDirection.UP -> { + val hasSubMinute = second > 0 || nano > 0 + val minutesToAdd = when (remainder) { + 0 if !hasSubMinute -> 0 + 0 -> intervalMinutes + else -> intervalMinutes - remainder + } + + minuteBase.plusMinutes(minutesToAdd.toLong()) + } + } +} + +enum class TimeRoundDirection { + UP, + DOWN, +} + diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/CallRecordIntent.kt b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/CallRecordIntent.kt new file mode 100644 index 0000000..d1853b7 --- /dev/null +++ b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/CallRecordIntent.kt @@ -0,0 +1,10 @@ +package kr.co.call.impl.viewmodel + +sealed interface CallRecordIntent { + + data class Load( + val callId: Long, + ) : CallRecordIntent + + data object Retry : CallRecordIntent +} diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/CallRecordSideEffect.kt b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/CallRecordSideEffect.kt new file mode 100644 index 0000000..f97d074 --- /dev/null +++ b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/CallRecordSideEffect.kt @@ -0,0 +1,8 @@ +package kr.co.call.impl.viewmodel + +sealed interface CallRecordSideEffect { + + data class ShowMessage( + val message: String, + ) : CallRecordSideEffect +} diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/CallRecordViewModel.kt b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/CallRecordViewModel.kt new file mode 100644 index 0000000..e0c9ca1 --- /dev/null +++ b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/CallRecordViewModel.kt @@ -0,0 +1,85 @@ +package kr.co.call.impl.viewmodel + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kr.co.call.domain.repository.CallRecordRepository +import kr.co.call.domain.util.LoadStatus +import kr.co.call.impl.mapper.toUiModel +import kr.co.call.impl.viewmodel.state.CallRecordState +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container + +@HiltViewModel +class CallRecordViewModel @Inject constructor( + private val callRecordRepository: CallRecordRepository, +) : ViewModel(), ContainerHost { + + override val container: Container = container( + initialState = CallRecordState(), + ) + + fun handleIntent(intent: CallRecordIntent) { + when (intent) { + is CallRecordIntent.Load -> loadCallRecord(callId = intent.callId) + CallRecordIntent.Retry -> retryLoad() + } + } + + private fun retryLoad() = intent { + val callId = state.callId ?: return@intent + loadCallRecord(callId = callId) + } + + private fun loadCallRecord(callId: Long) = intent { + reduce { + state.copy( + callId = callId, + loadStatus = LoadStatus.Loading, + ) + } + try { + // callInfo와 callScript를 동시에 가져오기 + val (callInfo, callScript) = coroutineScope { + val callInfoDeferred = async { + callRecordRepository.getCallInfo(callId).getOrThrow() + } + + val callScriptDeferred = async { + callRecordRepository.getCallScript(callId).getOrThrow() + } + + callInfoDeferred.await() to callScriptDeferred.await() + } + + // state에 저장 + reduce { + state.copy( + record = callInfo.toUiModel(), + transcripts = callScript, + loadStatus = LoadStatus.Idle, + ) + } + } catch (cancellationException: CancellationException) { + throw cancellationException + } catch (throwable: Throwable) { + val message = throwable.message ?: "통화 기록을 불러오지 못했습니다." + reduce { + state.copy( + loadStatus = LoadStatus.Error( + message = message, + ), + ) + } + postSideEffect( + CallRecordSideEffect.ShowMessage( + message = message, + ), + ) + } + } +} 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 index 0d99d3c..3e9e63a 100644 --- 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 @@ -1,15 +1,63 @@ package kr.co.call.impl.viewmodel +import java.time.LocalDate +import java.time.LocalTime import kr.co.call.impl.tab.HomeHistoryTab sealed interface HomeIntent { - data class SelectHistoryTab( - val tab: HomeHistoryTab, - ) : HomeIntent - data object ClickCall : HomeIntent + // 통화 기록 및 알림 관련 + sealed interface History : HomeIntent { + data class SelectTab( + val tab: HomeHistoryTab, + ) : History - data object ClickNotification : HomeIntent + data object ClickNotificationShortcut : History - data object ClickChangeTime : HomeIntent -} \ No newline at end of file + data class ClickRecord( + val callId: Long, + ) : History + } + + // 통화 관련 + sealed interface Call : HomeIntent { + data object ClickMain : Call + + data class ClickNotification( + val characterName: String, + ) : Call + + data object Confirm : Call + } + + // 시간 변경 + sealed interface TimeChange : HomeIntent { + data object Click : TimeChange + + data class SelectDate( + val date: LocalDate, + ) : TimeChange + + data class SelectTime( + val time: LocalTime, + ) : TimeChange + + data object Confirm : TimeChange + } + + // 캐릭터 관련 + sealed interface Character : HomeIntent { + data object ClickChange : Character + + data class Select( + val characterId: Long, + val characterName: String, + ) : Character + + data object ConfirmChange : Character + + data object ClickAdd : Character + } + + data object DismissDialog : HomeIntent +} 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 index b4dd835..f173a13 100644 --- 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 @@ -1,10 +1,19 @@ package kr.co.call.impl.viewmodel sealed interface HomeSideEffect { + + // 캐릭터 추가하기 -> 캐릭터 등록 화면으로 이동 + data object NavigateToCharacterOnboarding : HomeSideEffect + + // 전화하기 확인 -> 전화 화면으로 이동 + data object NavigateToCall : HomeSideEffect + + data class NavigateToCallRecord( + val callId: Long, + ) : 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 index 1c53592..6072fbb 100644 --- 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 @@ -2,12 +2,20 @@ package kr.co.call.impl.viewmodel import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime import kotlinx.coroutines.CancellationException -import kr.co.call.domain.usecase.home.HomeUseCase +import kr.co.call.domain.exception.CharacterChangeUnavailableException +import kr.co.call.domain.repository.HomeRepository 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.util.TimeRoundDirection +import kr.co.call.impl.util.roundToMinutes +import kr.co.call.impl.viewmodel.state.HomeDialogState import kr.co.call.impl.viewmodel.state.HomeState +import kr.co.call.impl.viewmodel.state.TimeChangeState import org.orbitmvi.orbit.Container import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container @@ -15,7 +23,7 @@ import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( - private val homeUseCase: HomeUseCase, + private val homeRepository: HomeRepository, ) : ViewModel(), ContainerHost { @@ -30,53 +38,287 @@ class HomeViewModel @Inject constructor( fun handleIntent(intent: HomeIntent) { when (intent) { - is HomeIntent.SelectHistoryTab -> { - selectHistoryTab(intent.tab) + is HomeIntent.History -> handleHistoryIntent(intent) + is HomeIntent.Call -> handleCallIntent(intent) + is HomeIntent.TimeChange -> handleTimeChangeIntent(intent) + is HomeIntent.Character -> handleCharacterIntent(intent) + HomeIntent.DismissDialog -> dismissDialog() + } + } + + private fun handleHistoryIntent(intent: HomeIntent.History) { + when (intent) { + is HomeIntent.History.SelectTab -> selectHistoryTab(intent.tab) + HomeIntent.History.ClickNotificationShortcut -> { + selectHistoryTab(HomeHistoryTab.NOTIFICATION) } + is HomeIntent.History.ClickRecord -> navigateToCallRecord(intent.callId) + } + } - HomeIntent.ClickCall -> { - // 전화 화면 이동 + private fun handleCallIntent(intent: HomeIntent.Call) { + when (intent) { + HomeIntent.Call.ClickMain -> showMainCharacterCallConfirmation() + is HomeIntent.Call.ClickNotification -> { + showCallDialog(characterName = intent.characterName) } + HomeIntent.Call.Confirm -> confirmCall() + } + } - HomeIntent.ClickNotification -> { - // 알림 화면 이동 + private fun handleTimeChangeIntent(intent: HomeIntent.TimeChange) { + when (intent) { + HomeIntent.TimeChange.Click -> showTimeChangeBottomSheet() + is HomeIntent.TimeChange.SelectDate -> selectChangeDate(intent.date) + is HomeIntent.TimeChange.SelectTime -> selectChangeTime(intent.time) + HomeIntent.TimeChange.Confirm -> confirmTimeChange() + } + } + + private fun handleCharacterIntent(intent: HomeIntent.Character) { + when (intent) { + HomeIntent.Character.ClickChange -> showCharacterSelectionDialog() + is HomeIntent.Character.Select -> { + showCharacterChangeConfirmation( + characterId = intent.characterId, + characterName = intent.characterName, + ) } + HomeIntent.Character.ConfirmChange -> confirmCharacterChange() + HomeIntent.Character.ClickAdd -> navigateToCharacterOnboarding() + } + } + + // 메인 캐릭터 통화 확인 팝업 표시 + private fun showMainCharacterCallConfirmation() = intent { + val mainCharacter = state.characters.firstOrNull { character -> character.isSelected } + ?: return@intent + + reduce { + state.copy( + dialogState = HomeDialogState.CallConfirmation( + characterId = mainCharacter.characterId, + characterName = mainCharacter.name, + ), + ) + } + } + + // 통화 대상에 맞는 팝업 표시 + private fun showCallDialog(characterName: String) = intent { + val mainCharacter = state.characters.firstOrNull { character -> character.isSelected } + val dialogState = if (mainCharacter?.name == characterName) { + HomeDialogState.CallConfirmation( + characterId = mainCharacter.characterId, + characterName = characterName, + ) + } else { + HomeDialogState.NonMainCharacterCall(characterName = characterName) + } + + reduce { + state.copy(dialogState = dialogState) + } + } + + // 통화 연결 화면으로 이동 + private fun confirmCall() = intent { + val confirmation = state.dialogState as? HomeDialogState.CallConfirmation + ?: return@intent + + try { + homeRepository.startCall( + characterId = confirmation.characterId, + ).getOrThrow() - HomeIntent.ClickChangeTime -> { - // 시간 변경 화면 이동 + reduce { + state.copy(dialogState = null) } + postSideEffect(HomeSideEffect.NavigateToCall) + } catch (cancellationException: CancellationException) { + throw cancellationException + } catch (throwable: Throwable) { + postSideEffect( + HomeSideEffect.ShowMessage( + message = throwable.message ?: "통화를 시작하지 못했습니다.", + ), + ) } } - private fun loadHome() = intent { + // 캐릭터 선택 팝업 표시 + private fun showCharacterSelectionDialog() = intent { reduce { - state.copy(loadStatus = LoadStatus.Loading) + state.copy(dialogState = HomeDialogState.CharacterSelection) } + } + + // 캐릭터 선택 확인/취소 + private fun showCharacterChangeConfirmation( + characterId: Long, + characterName: String, + ) = intent { + reduce { + state.copy( + dialogState = HomeDialogState.CharacterChangeConfirmation( + characterId = characterId, + characterName = characterName, + ), + ) + } + } + + // 캐릭터 변경 확인 + private fun confirmCharacterChange() = intent { + val confirmation = state.dialogState as? HomeDialogState.CharacterChangeConfirmation + ?: return@intent try { - // 데이터 한번에 불러오기 - val home = homeUseCase() + val characters = homeRepository.changeMainCharacter( + characterId = confirmation.characterId, + ).getOrThrow() + reduce { state.copy( - summary = home.summary.toUiModel(), - reservation = home.reservations.toUiModel(), - callHistories = home.callHistories.map { history -> history.toUiModel() }, - loadStatus = LoadStatus.Idle, + characters = characters.map { character -> character.toUiModel() }, + dialogState = null, ) } + postSideEffect( + HomeSideEffect.ShowMessage( + message = "${confirmation.characterName}로 메인 연인을 교체했습니다.", + ), + ) } catch (cancellationException: CancellationException) { throw cancellationException - } catch (throwable: Throwable) { + } catch (exception: CharacterChangeUnavailableException) { reduce { state.copy( - loadStatus = LoadStatus.Error( - message = throwable.message ?: "홈 정보를 불러오지 못했습니다.", + dialogState = HomeDialogState.CharacterChangeUnavailable, + ) + } + } catch (throwable: Throwable) { + postSideEffect( + HomeSideEffect.ShowMessage( + message = throwable.message ?: "메인 연인을 변경하지 못했습니다.", + ), + ) + } + } + + + // 날짜 선택 + private fun selectChangeDate(date: LocalDate) = intent { + val timeChange = state.dialogState as? HomeDialogState.TimeChange + ?: return@intent + + reduce { + state.copy( + dialogState = timeChange.copy( + state = timeChange.state.copy(selectedDate = date), + ), + ) + } + } + + private fun showTimeChangeBottomSheet() = intent { + + val initialDateTime = LocalDateTime.now().roundToMinutes( + intervalMinutes = 30, + direction = TimeRoundDirection.UP, + ) + + reduce { + state.copy( + dialogState = HomeDialogState.TimeChange( + state = TimeChangeState( + selectedDate = initialDateTime.toLocalDate(), + selectedTime = initialDateTime.toLocalTime(), ), + ), + ) + } + } + + // 선택한 예약 시간 확정 + private fun confirmTimeChange() = intent { + val timeChange = state.dialogState as? HomeDialogState.TimeChange + ?: return@intent + val reservationId = state.reservation.reservationId + if (reservationId == null) { + postSideEffect( + HomeSideEffect.ShowMessage( + message = "변경할 예약을 찾을 수 없습니다.", + ), + ) + return@intent + } + + try { + val reservations = homeRepository.changeReservationTime( + reservationId = reservationId, + scheduledAt = LocalDateTime.of( + timeChange.state.selectedDate, + timeChange.state.selectedTime, + ), + ).getOrThrow() + + reduce { + state.copy( + reservation = reservations.toUiModel(), + dialogState = null, ) } + postSideEffect( + HomeSideEffect.ShowMessage( + message = "예약 시간을 변경했습니다.", + ), + ) + } catch (cancellationException: CancellationException) { + throw cancellationException + } catch (throwable: Throwable) { + postSideEffect( + HomeSideEffect.ShowMessage( + message = throwable.message ?: "예약 시간을 변경하지 못했습니다.", + ), + ) } } + // 시간 변경 선택 + private fun selectChangeTime(time: LocalTime) = intent { + val timeChange = state.dialogState as? HomeDialogState.TimeChange + ?: return@intent + + reduce { + state.copy( + dialogState = timeChange.copy( + state = timeChange.state.copy(selectedTime = time), + ), + ) + } + } + + + // 온보딩으로 이동 + private fun navigateToCharacterOnboarding() = intent { + reduce { + state.copy(dialogState = null) + } + postSideEffect(HomeSideEffect.NavigateToCharacterOnboarding) + } + + private fun navigateToCallRecord(callId: Long) = intent { + postSideEffect(HomeSideEffect.NavigateToCallRecord(callId = callId)) + } + + private fun dismissDialog() = intent { + reduce { + state.copy(dialogState = null) + } + } + + // 기록 탭 선택 private fun selectHistoryTab( tab: HomeHistoryTab, @@ -87,4 +329,37 @@ class HomeViewModel @Inject constructor( ) } } + + private fun loadHome() = intent { + reduce { + state.copy(loadStatus = LoadStatus.Loading) + } + + try { + val summary = homeRepository.getSummary().getOrThrow() + val reservations = homeRepository.getReservations().getOrThrow() + val callHistories = homeRepository.getCallHistories().getOrThrow() + val characters = homeRepository.getCharacters().getOrThrow() + + reduce { + state.copy( + summary = summary.toUiModel(), + reservation = reservations.toUiModel(), + callHistories = callHistories.map { history -> history.toUiModel() }, + characters = characters.map { character -> character.toUiModel() }, + loadStatus = LoadStatus.Idle, + ) + } + } catch (cancellationException: CancellationException) { + throw cancellationException + } catch (throwable: Throwable) { + reduce { + state.copy( + loadStatus = LoadStatus.Error( + message = throwable.message ?: "홈 정보를 불러오지 못했습니다.", + ), + ) + } + } + } } diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/model/CallRecordUiModel.kt b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/model/CallRecordUiModel.kt new file mode 100644 index 0000000..588a725 --- /dev/null +++ b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/model/CallRecordUiModel.kt @@ -0,0 +1,8 @@ +package kr.co.call.impl.viewmodel.model + +data class CallRecordUiModel( + val title: String = "", + val calledAtText: String = "", + val characterName: String = "", + val recordingUrl: String? = null, +) 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 index 74f6eea..5f3d182 100644 --- 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 @@ -1,6 +1,7 @@ package kr.co.call.impl.viewmodel.model data class HomeReservationUiModel( + val reservationId: Long? = null, val hasReservation: Boolean = false, val reservationCountText: String = "약속 0건", val profileImageUrl: String? = null, diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/state/CallRecordState.kt b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/state/CallRecordState.kt new file mode 100644 index 0000000..f20d75c --- /dev/null +++ b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/state/CallRecordState.kt @@ -0,0 +1,12 @@ +package kr.co.call.impl.viewmodel.state + +import kr.co.call.domain.model.home.CallTranscript +import kr.co.call.domain.util.LoadStatus +import kr.co.call.impl.viewmodel.model.CallRecordUiModel + +data class CallRecordState( + val callId: Long? = null, + val record: CallRecordUiModel = CallRecordUiModel(), + val transcripts: List = emptyList(), + val loadStatus: LoadStatus = LoadStatus.Loading, +) diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/state/HomeDialogState.kt b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/state/HomeDialogState.kt new file mode 100644 index 0000000..0a2927b --- /dev/null +++ b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/state/HomeDialogState.kt @@ -0,0 +1,31 @@ +package kr.co.call.impl.viewmodel.state + +/** + * 홈 화면 다이얼로그 상태 + * @property CharacterSelection 메인 연인 선택 + * @property CharacterChangeUnavailable 메인 연인 변경 제한 안내 + * @property CharacterChangeConfirmation 메인 연인 교체 확인 + */ +sealed interface HomeDialogState { + data object CharacterSelection : HomeDialogState + + data object CharacterChangeUnavailable : HomeDialogState + + data class CharacterChangeConfirmation( + val characterId: Long, + val characterName: String, + ) : HomeDialogState + + data class TimeChange( + val state: TimeChangeState, + ) : HomeDialogState + + data class CallConfirmation( + val characterId: Long, + val characterName: String, + ) : HomeDialogState + + data class NonMainCharacterCall( + val characterName: String, + ) : HomeDialogState +} 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 index 3dbc454..914fe38 100644 --- 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 @@ -3,6 +3,7 @@ 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.CharacterOptionUiModel import kr.co.call.impl.viewmodel.model.HomeReservationUiModel import kr.co.call.impl.viewmodel.model.HomeSummaryUiModel @@ -13,7 +14,6 @@ data class HomeState( val hasUnreadNotification: Boolean = true, val loadStatus: LoadStatus = LoadStatus.Idle, val selectedHistoryTab: HomeHistoryTab = HomeHistoryTab.NOTIFICATION, -) { - val isLoading: Boolean - get() = loadStatus == LoadStatus.Loading -} + val characters: List = emptyList(), + val dialogState: HomeDialogState? = null, +) diff --git a/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/state/TimeChangeState.kt b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/state/TimeChangeState.kt new file mode 100644 index 0000000..32e0a0d --- /dev/null +++ b/feature/home/impl/src/main/java/kr/co/call/impl/viewmodel/state/TimeChangeState.kt @@ -0,0 +1,12 @@ +package kr.co.call.impl.viewmodel.state + +import java.time.LocalDate +import java.time.LocalTime + +/** + * 시간 변경 바텀시트 상태 + */ +data class TimeChangeState( + val selectedDate: LocalDate, + val selectedTime: LocalTime, +) diff --git a/feature/home/impl/src/test/java/kr/co/call/impl/util/DateTimeRoundingTest.kt b/feature/home/impl/src/test/java/kr/co/call/impl/util/DateTimeRoundingTest.kt new file mode 100644 index 0000000..af87712 --- /dev/null +++ b/feature/home/impl/src/test/java/kr/co/call/impl/util/DateTimeRoundingTest.kt @@ -0,0 +1,50 @@ +package kr.co.call.impl.util + +import java.time.LocalDateTime +import org.junit.Assert.assertEquals +import org.junit.Test + +class DateTimeRoundingTest { + + @Test + fun `30분 단위로 내림한다`() { + val dateTime = LocalDateTime.of(2026, 7, 23, 10, 17, 20) + + val result = dateTime.roundToMinutes( + intervalMinutes = 30, + direction = TimeRoundDirection.DOWN, + ) + + assertEquals( + LocalDateTime.of(2026, 7, 23, 10, 0), + result, + ) + } + + @Test + fun `30분 단위로 올림한다`() { + val dateTime = LocalDateTime.of(2026, 7, 23, 10, 17, 20) + + val result = dateTime.roundToMinutes( + intervalMinutes = 30, + direction = TimeRoundDirection.UP, + ) + + assertEquals( + LocalDateTime.of(2026, 7, 23, 10, 30), + result, + ) + } + + @Test + fun `이미 경계 시각이면 올림해도 유지한다`() { + val dateTime = LocalDateTime.of(2026, 7, 23, 10, 30) + + val result = dateTime.roundToMinutes( + intervalMinutes = 30, + direction = TimeRoundDirection.UP, + ) + + assertEquals(dateTime, result) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4d2d7da..6956087 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,9 @@ jetbrainsKotlinJvm = "2.2.20" appcompat = "1.7.1" material = "1.14.0" +# media3 +media3 = "1.10.1" + hilt = "2.56.2" hiltNavigationCompose = "1.2.0" @@ -44,6 +47,7 @@ ksp = "2.2.20-2.0.4" runner = "1.0.2" espressoCoreVersion = "3.0.2" appcompatV7 = "28.0.0" +uiGraphics = "1.11.4" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -116,6 +120,9 @@ orbit-core = { module = "org.orbit-mvi:orbit-core", version.ref = "orbit" } orbit-viewmodel = { module = "org.orbit-mvi:orbit-viewmodel", version.ref = "orbit" } orbit-compose = { module = "org.orbit-mvi:orbit-compose", version.ref = "orbit" } orbit-test = { module = "org.orbit-mvi:orbit-test", version.ref = "orbit" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "uiGraphics" } + +androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -128,4 +135,3 @@ ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } -