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 7e64603..2b09366 100644 --- a/app/src/main/java/kr/co/call/callfromai/AppScreen.kt +++ b/app/src/main/java/kr/co/call/callfromai/AppScreen.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -17,7 +16,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity @@ -28,6 +26,7 @@ import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import kr.co.call.api.CallRecordNavKey +import kr.co.call.api.CharacterManagementNavKey import kr.co.call.api.ChatRoomNavKey import kr.co.call.api.ChattingNavKey import kr.co.call.api.FaqNavKey @@ -37,13 +36,13 @@ import kr.co.call.api.MyPageNavKey import kr.co.call.api.TermNavKey import kr.co.call.callfromai.ui.MainBottomBar import kr.co.call.callfromai.ui.MainTab +import kr.co.call.callfromai.util.toMainTab import kr.co.call.designsystem.component.LocalBottomBarPadding import kr.co.call.impl.entry.chattingEntry import kr.co.call.impl.entry.homeEntry import kr.co.call.impl.entry.loginEntry import kr.co.call.impl.entry.myPageEntry import kr.co.call.impl.entry.onboardingEntry -import kr.co.call.callfromai.util.toMainTab /** * 애플리케이션 화면 내비게이션의 메인 진입점입니다. @@ -89,20 +88,19 @@ fun AppScreen(modifier: Modifier = Modifier) { modifier = modifier.fillMaxSize(), bottomBar = { if (showBottomBar) { - MainBottomBar( - currentTab = currentTab, - onTabSelected = appNavigator::navigateToTab, - modifier = Modifier - .onSizeChanged { bottomBarHeightPx = it.height }, - ) + MainBottomBar( + currentTab = currentTab, + onTabSelected = appNavigator::navigateToTab, + modifier = Modifier + .onSizeChanged { bottomBarHeightPx = it.height }, + ) } }, contentWindowInsets = WindowInsets.safeDrawing .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) .exclude(WindowInsets.ime), - ) { - padding -> + ) { padding -> CompositionLocalProvider( LocalBottomBarPadding provides if (showBottomBar) bottomBarPadding else 0.dp, ) { @@ -138,6 +136,7 @@ fun AppScreen(modifier: Modifier = Modifier) { myPageEntry( navigateToFaq = { appNavigator.navigate(FaqNavKey) }, navigateToTerms = { appNavigator.navigate(TermNavKey) }, + navigateToCharacterManagement = { appNavigator.navigate(CharacterManagementNavKey) }, onBack = { appNavigator.popBackStack() }, ) } 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 a5b63eb..a1d07b0 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,18 +4,21 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kr.co.call.data.repositoryImpl.AICharacterRepositoryImpl 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.AICharacterRepository import kr.co.call.domain.repository.CallRecordRepository import kr.co.call.domain.repository.ChatRepository import kr.co.call.domain.repository.HomeRepository import kr.co.call.domain.repository.FaqRepository import kr.co.call.domain.repository.MyPageRepository + @Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { @@ -44,6 +47,12 @@ abstract class RepositoryModule { impl: MyPageRepositoryImpl ): MyPageRepository + @Binds + @Singleton + abstract fun bindCharacterRepository( + impl: AICharacterRepositoryImpl + ): AICharacterRepository + @Binds @Singleton abstract fun bindFaqRepository( diff --git a/core/data/src/main/java/kr/co/call/data/repositoryImpl/AICharacterRepositoryImpl.kt b/core/data/src/main/java/kr/co/call/data/repositoryImpl/AICharacterRepositoryImpl.kt new file mode 100644 index 0000000..0a58432 --- /dev/null +++ b/core/data/src/main/java/kr/co/call/data/repositoryImpl/AICharacterRepositoryImpl.kt @@ -0,0 +1,45 @@ +package kr.co.call.data.repositoryImpl + +import kr.co.call.domain.model.mypage.AiCharacter +import kr.co.call.domain.repository.AICharacterRepository +import javax.inject.Inject + +// TODO: API 연동 전 임시 구현체 +class AICharacterRepositoryImpl @Inject constructor() : AICharacterRepository { + + private val aiCharacters = mutableListOf( + AiCharacter( + id = "1", name = "김민준", profileImageUrl = "", isMain = true, + createdAtLabel = "2025.05.26", daysTogetherLabel = "35일 째", lastConversationLabel = "오늘 21:42", + summary = "아이스크림을 커피보다 좋아하며, 늦은 저녁 시간에 대화하는 것을 선호해요. 디자인과 여행 이야기에 관심이 많고, 반말대를 편하게 여겨요.", + ), + AiCharacter( + id = "2", name = "박동휘", profileImageUrl = "", isMain = false, + createdAtLabel = "2025.06.19", daysTogetherLabel = "11일 째", lastConversationLabel = "6/29 12:30", + summary = "운동과 액티비티를 좋아하고, 아침 시간대 대화를 선호해요.", + ), + AiCharacter( + id = "3", name = "정유나", profileImageUrl = "", isMain = false, + createdAtLabel = "2025.06.25", daysTogetherLabel = "5일 째", lastConversationLabel = "6/28 09:04", + summary = "잔잔한 음악과 독서를 좋아하고, 차분한 대화를 선호해요.", + ), + ) + + override suspend fun getCharacters(): Result>{ + return runCatching { + aiCharacters.toList() + } + } + + override suspend fun deleteCharacter(characterId: String): Result { + return runCatching { + aiCharacters.removeAll { it.id == characterId } + Unit + } + } + + override suspend fun canAddCharacter(): Result { + // TODO: 마지막 캐릭터 생성 시각 기준 24시간 경과 여부를 판단(백엔드) + return runCatching { false } + } +} \ No newline at end of file diff --git a/core/designsystem/src/main/java/kr/co/call/designsystem/component/button/SecondaryButton.kt b/core/designsystem/src/main/java/kr/co/call/designsystem/component/button/SecondaryButton.kt index c42acab..f98c69f 100644 --- a/core/designsystem/src/main/java/kr/co/call/designsystem/component/button/SecondaryButton.kt +++ b/core/designsystem/src/main/java/kr/co/call/designsystem/component/button/SecondaryButton.kt @@ -7,10 +7,15 @@ import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.runtime.Composable @@ -19,6 +24,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kr.co.call.designsystem.theme.CallFromAiTheme @@ -33,6 +40,7 @@ fun SecondaryButton( text: String, onClick: () -> Unit, enabled: Boolean = true, + iconRes: Int? = null, containerColor: Color = CallTheme.colors.mainVariant4, contentColor: Color = CallTheme.colors.white, pressedContainerColor: Color = CallTheme.colors.subPressed3, @@ -60,11 +68,22 @@ fun SecondaryButton( ), contentAlignment = Alignment.Center, ) { - Text( - text = text, - style = CallTheme.typography.bodyMediumMedium, - color = if (enabled) contentColor else contentColor.copy(alpha = 0.38f), - ) + val contentTint = if (enabled) contentColor else contentColor.copy(alpha = 0.38f) + + if (iconRes != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = contentTint, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = text, style = CallTheme.typography.bodyMediumMedium, color = contentTint) + } + } else { + Text(text = text, style = CallTheme.typography.bodyMediumMedium, color = contentTint) + } } } diff --git a/core/designsystem/src/main/java/kr/co/call/designsystem/component/popup/OneButtonPopup.kt b/core/designsystem/src/main/java/kr/co/call/designsystem/component/popup/OneButtonPopup.kt index cf33781..4634a8a 100644 --- a/core/designsystem/src/main/java/kr/co/call/designsystem/component/popup/OneButtonPopup.kt +++ b/core/designsystem/src/main/java/kr/co/call/designsystem/component/popup/OneButtonPopup.kt @@ -19,7 +19,7 @@ import kr.co.call.designsystem.theme.CallTheme fun OneButtonPopup( modifier: Modifier = Modifier, label: String, - title: String, + title: String? = null, buttonText: String, onButtonClick: () -> Unit, description: AnnotatedString? = null, diff --git a/core/designsystem/src/main/java/kr/co/call/designsystem/component/popup/PopupCard.kt b/core/designsystem/src/main/java/kr/co/call/designsystem/component/popup/PopupCard.kt index 494db6c..fcd4657 100644 --- a/core/designsystem/src/main/java/kr/co/call/designsystem/component/popup/PopupCard.kt +++ b/core/designsystem/src/main/java/kr/co/call/designsystem/component/popup/PopupCard.kt @@ -33,7 +33,7 @@ import kr.co.call.designsystem.theme.CallTheme internal fun PopupCard( modifier: Modifier = Modifier, label: String, - title: String, + title: String?, description: AnnotatedString? = null, labelSpacerHeight: Dp = 8.dp, descriptionSpacerHeight: Dp = 8.dp, @@ -59,13 +59,16 @@ internal fun PopupCard( textAlign = TextAlign.Center, ) Spacer(modifier = Modifier.height(labelSpacerHeight)) - Text( - text = title, - style = CallTheme.typography.bodyLargeBold, - color = CallTheme.colors.black, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) + if (title != null) { + Spacer(modifier = Modifier.height(labelSpacerHeight)) + Text( + text = title, + style = CallTheme.typography.bodyLargeBold, + color = CallTheme.colors.black, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } if (description != null) { Spacer(modifier = Modifier.height(descriptionSpacerHeight)) Text( diff --git a/core/designsystem/src/main/res/drawable/ic_mypage_edit.xml b/core/designsystem/src/main/res/drawable/ic_mypage_edit.xml new file mode 100644 index 0000000..04f24f7 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_mypage_edit.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_mypage_trash.xml b/core/designsystem/src/main/res/drawable/ic_mypage_trash.xml new file mode 100644 index 0000000..78952fd --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_mypage_trash.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/core/domain/src/main/java/kr/co/call/domain/model/mypage/AiCharacter.kt b/core/domain/src/main/java/kr/co/call/domain/model/mypage/AiCharacter.kt new file mode 100644 index 0000000..ea36775 --- /dev/null +++ b/core/domain/src/main/java/kr/co/call/domain/model/mypage/AiCharacter.kt @@ -0,0 +1,12 @@ +package kr.co.call.domain.model.mypage + +data class AiCharacter( + val id: String= "", + val name: String= "", + val profileImageUrl: String= "", + val isMain: Boolean, + val createdAtLabel: String= "", + val daysTogetherLabel: String= "", + val lastConversationLabel: String= "", + val summary: String= "", +) \ No newline at end of file diff --git a/core/domain/src/main/java/kr/co/call/domain/repository/AICharacterRepository.kt b/core/domain/src/main/java/kr/co/call/domain/repository/AICharacterRepository.kt new file mode 100644 index 0000000..174f969 --- /dev/null +++ b/core/domain/src/main/java/kr/co/call/domain/repository/AICharacterRepository.kt @@ -0,0 +1,9 @@ +package kr.co.call.domain.repository + +import kr.co.call.domain.model.mypage.AiCharacter + +interface AICharacterRepository { + suspend fun getCharacters(): Result> + suspend fun deleteCharacter(characterId: String): Result + suspend fun canAddCharacter(): Result +} \ No newline at end of file diff --git a/feature/mypage/api/src/main/java/kr/co/call/api/MyPageRoute.kt b/feature/mypage/api/src/main/java/kr/co/call/api/MyPageRoute.kt index 637475d..d0ba5e0 100644 --- a/feature/mypage/api/src/main/java/kr/co/call/api/MyPageRoute.kt +++ b/feature/mypage/api/src/main/java/kr/co/call/api/MyPageRoute.kt @@ -6,6 +6,9 @@ import kotlinx.serialization.Serializable @Serializable data object MyPageNavKey: NavKey +@Serializable +data object CharacterManagementNavKey: NavKey + @Serializable data object FaqNavKey : NavKey diff --git a/feature/mypage/impl/src/main/java/kr/co/call/impl/component/CharacterCard.kt b/feature/mypage/impl/src/main/java/kr/co/call/impl/component/CharacterCard.kt new file mode 100644 index 0000000..decfa5f --- /dev/null +++ b/feature/mypage/impl/src/main/java/kr/co/call/impl/component/CharacterCard.kt @@ -0,0 +1,172 @@ +package kr.co.call.impl.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import kr.co.call.designsystem.R +import kr.co.call.designsystem.component.button.SecondaryButton +import kr.co.call.designsystem.theme.CallFromAiTheme +import kr.co.call.designsystem.theme.CallTheme +import kr.co.call.domain.model.mypage.AiCharacter + +/** AI 캐릭터 관리 목록 아이템 카드 */ +@Composable +fun CharacterCard( + aiCharacter: AiCharacter, + onChatHistoryClick: () -> Unit, + onDeleteClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val cardShape = RoundedCornerShape(20.dp) + + Column( + modifier = modifier + .fillMaxWidth() + .clip(cardShape) + .background(CallTheme.colors.white) + .border(width = 1.dp, color = CallTheme.colors.gray200, shape =cardShape ) + .padding(horizontal = 20.dp) + .padding(top = 26.dp, bottom = 21.dp), + ) { + Row{ + Box { + // 프로필 이미지 + AsyncImage( + model = aiCharacter.profileImageUrl, + contentDescription = aiCharacter.name, + contentScale = ContentScale.Crop, + modifier = Modifier.size(60.dp).clip(CircleShape), + placeholder = painterResource(id = R.drawable.img_mypage_profile_default), + error = painterResource(id = R.drawable.img_mypage_profile_default), + ) + // 프로필 편집하기 아이콘 + // TODO : 추후 캐릭터 편집 화면으로 이동 구현 + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(23.dp) + .clip(CircleShape) + .background(CallTheme.colors.white), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_mypage_edit), + contentDescription = "편집하기 버튼", + tint = Color.Unspecified + ) + } + } + + Spacer(modifier = Modifier.width(11.dp)) + + Column{ + Row{ + Text(text = aiCharacter.name, style = CallTheme.typography.bodyMedium, color = CallTheme.colors.black) + + // 메인 캐릭터 뱃지 + if (aiCharacter.isMain) { + Spacer(modifier = Modifier.width(7.dp)) + MainBadge() + } + + Spacer(modifier = Modifier.weight(1f)) + + // 채팅 기록 보기 버튼 + Text( + text = "채팅 기록 보기", + style = CallTheme.typography.caption, + color = CallTheme.colors.gray400, + textDecoration = TextDecoration.Underline, + modifier = Modifier.clickable(onClick = onChatHistoryClick), + ) + } + Spacer(modifier= Modifier.height(20.dp)) + + Row(modifier = Modifier.fillMaxWidth()) { + InfoColumn(label = "생성일", value = aiCharacter.createdAtLabel, modifier = Modifier.weight(1f)) + VerticalDivider( + thickness = 1.dp, + color = CallTheme.colors.gray100, + modifier = Modifier.height(30.dp) + ) + InfoColumn(label = "함께한 지", value = aiCharacter.daysTogetherLabel, modifier = Modifier.weight(1f)) + VerticalDivider( + thickness = 1.dp, + color = CallTheme.colors.gray100, + modifier = Modifier.height(30.dp) + ) + InfoColumn(label = "마지막 대화", value = aiCharacter.lastConversationLabel, modifier = Modifier.weight(1f)) + } + } + } + Spacer(modifier = Modifier.height(24.dp)) + + SecondaryButton( + text = "캐릭터 영구 삭제", + onClick = onDeleteClick, + iconRes = R.drawable.ic_mypage_trash, + containerColor = CallTheme.colors.mainVariant1, + modifier = Modifier.height(38.dp) + ) + + } +} + +@Composable +private fun InfoColumn(label: String, value: String, modifier: Modifier = Modifier) { + Column(modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = label, style = CallTheme.typography.caption, color = CallTheme.colors.gray400) + Spacer(modifier = Modifier.height(3.dp)) + Text(text = value, style = CallTheme.typography.captionBold, color = CallTheme.colors.black) + } +} + +@Composable +private fun MainBadge(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .clip(RoundedCornerShape(30.dp)) + .background(CallTheme.colors.mainVariant5Chat) + .padding(horizontal = 10.dp, vertical = 3.dp), + ) { + Text(text = "메인", style = CallTheme.typography.captionBold, color = CallTheme.colors.mainVariant1) + } +} + +@Preview(showBackground = true) +@Composable +private fun CharacterCardPreview() { + CallFromAiTheme { + CharacterCard( + aiCharacter = AiCharacter("1", "김민준", "", true, "2025.05.26", "35일 째", "오늘 21:42", "요약 텍스트"), + onChatHistoryClick = {}, + onDeleteClick = {}, + ) + } +} \ No newline at end of file diff --git a/feature/mypage/impl/src/main/java/kr/co/call/impl/entry/MyPageEntryBuilder.kt b/feature/mypage/impl/src/main/java/kr/co/call/impl/entry/MyPageEntryBuilder.kt index 5f44286..beecb20 100644 --- a/feature/mypage/impl/src/main/java/kr/co/call/impl/entry/MyPageEntryBuilder.kt +++ b/feature/mypage/impl/src/main/java/kr/co/call/impl/entry/MyPageEntryBuilder.kt @@ -2,25 +2,36 @@ package kr.co.call.impl.entry import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey +import kr.co.call.api.CharacterManagementNavKey import kr.co.call.api.FaqNavKey import kr.co.call.api.MyPageNavKey +import kr.co.call.impl.screen.CharacterManagementScreen import kr.co.call.api.TermNavKey import kr.co.call.impl.screen.FaqScreen import kr.co.call.impl.screen.MyPageScreen import kr.co.call.impl.screen.TermScreen fun EntryProviderScope.myPageEntry( + navigateToCharacterManagement: () -> Unit, navigateToFaq: () -> Unit, navigateToTerms: () -> Unit, onBack: () -> Unit = {}, ) { entry { MyPageScreen( + onNavigateToCharacterManagement = navigateToCharacterManagement, navigateToFaq = navigateToFaq, navigateToTerms = navigateToTerms, ) } + entry { + CharacterManagementScreen( + onBackClick = onBack, + navigateToAddCharacter = { /* TODO: 캐릭터 추가 화면 연결 */ }, + ) + } + entry { FaqScreen(onBackClick = onBack) } diff --git a/feature/mypage/impl/src/main/java/kr/co/call/impl/screen/CharacterManagementScreen.kt b/feature/mypage/impl/src/main/java/kr/co/call/impl/screen/CharacterManagementScreen.kt new file mode 100644 index 0000000..d5c3aad --- /dev/null +++ b/feature/mypage/impl/src/main/java/kr/co/call/impl/screen/CharacterManagementScreen.kt @@ -0,0 +1,305 @@ +package kr.co.call.impl.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.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 androidx.hilt.navigation.compose.hiltViewModel +import kr.co.call.designsystem.component.button.SecondaryButton +import kr.co.call.designsystem.component.popup.OneButtonPopup +import kr.co.call.designsystem.component.popup.TwoButtonPopup +import kr.co.call.designsystem.theme.CallFromAiTheme +import kr.co.call.designsystem.theme.CallTheme +import kr.co.call.domain.model.mypage.AiCharacter +import kr.co.call.impl.component.CharacterCard +import kr.co.call.impl.component.CommonTopAppBar +import kr.co.call.impl.viewmodel.CharacterManagementIntent +import kr.co.call.impl.viewmodel.CharacterManagementSideEffect +import kr.co.call.impl.viewmodel.CharacterManagementState +import kr.co.call.impl.viewmodel.CharacterManagementViewModel +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect + +@Composable +fun CharacterManagementScreen( + modifier: Modifier = Modifier, + viewModel: CharacterManagementViewModel = hiltViewModel(), + onBackClick: () -> Unit, + navigateToAddCharacter: () -> Unit, +) { + val state by viewModel.collectAsState() + + var showChatHistory by remember { mutableStateOf(false) } + var showDeleteConfirm by remember { mutableStateOf(false) } + var showMainDeleteBlocked by remember { mutableStateOf(false) } + var showAddBlocked by remember { mutableStateOf(false) } + + // 팝업 내용 + var chatHistoryCharacter by remember { mutableStateOf(null) } + var deleteCharacter by remember { mutableStateOf(null) } + + viewModel.collectSideEffect { sideEffect -> + when (sideEffect) { + is CharacterManagementSideEffect.ShowChatHistorySummary -> { + chatHistoryCharacter = sideEffect.aiCharacter + showChatHistory = true + } + is CharacterManagementSideEffect.ShowDeleteConfirmDialog -> { + deleteCharacter = sideEffect.aiCharacter + showDeleteConfirm = true + } + is CharacterManagementSideEffect.ShowMainCharacterDeleteBlocked -> showMainDeleteBlocked = true + is CharacterManagementSideEffect.ShowAddCharacterBlocked -> showAddBlocked = true + is CharacterManagementSideEffect.NavigateToAddCharacter -> navigateToAddCharacter() + } + } + + CharacterManagementScreenContent( + state = state, + onIntent = viewModel::handleIntent, + onBackClick = onBackClick, + modifier = modifier, + ) + + // 팝업 - 채팅 기록 보기 + if (showChatHistory) { + chatHistoryCharacter?.let { character -> + OneButtonPopup( + label = "채팅 기록 보기", + description = buildAnnotatedString { append(character.summary) }, + buttonText = "확인", + onButtonClick = { showChatHistory = false }, + onDismissRequest = { showChatHistory = false }, + ) + } + } + + // 팝업 - 캐릭터 영구 삭제 확인 + if (showDeleteConfirm) { + deleteCharacter?.let { character -> + TwoButtonPopup( + label = "캐릭터 영구 삭제", + title = "${character.name}와 헤어지시겠습니까?", + description = buildAnnotatedString { append("데이터 복구 및 재사용 불가합니다.") }, + positiveText = "삭제", + negativeText = "취소", + onPositiveClick = { + viewModel.handleIntent( + CharacterManagementIntent.ConfirmDeleteCharacter(character) + ) + showDeleteConfirm = false + }, + onNegativeClick = { showDeleteConfirm = false }, + onDismissRequest = { showDeleteConfirm = false }, + ) + } + } + + // 팝업 - 캐릭터 영구 삭제 + if (showMainDeleteBlocked) { + OneButtonPopup( + label = "캐릭터 영구 삭제", + title = "현재 메인으로 선택된\n연인은 삭제가 불가능합니다.", + buttonText = "확인", + onButtonClick = { showMainDeleteBlocked = false }, + onDismissRequest = { showMainDeleteBlocked = false }, + ) + } + + // 팝업 - 캐릭터 추가하기(24시간 생성 제약) + if (showAddBlocked) { + OneButtonPopup( + label = "캐릭터 추가하기", + title = "아직 캐릭터를\n추가할 수 없어요", + description = buildAnnotatedString { + append("새로운 캐릭터를 만든 후 ") + withStyle(SpanStyle(color = CallTheme.colors.mainVariant1)) { + append("24시간") + } + append("이 지나면\n또 다른 캐릭터를 만들 수 있어요") + }, + buttonText = "확인", + onButtonClick = { showAddBlocked = false }, + onDismissRequest = { showAddBlocked = false }, + ) + } +} + +@Composable +private fun CharacterManagementScreenContent( + state: CharacterManagementState, + onIntent: (CharacterManagementIntent) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .background(CallTheme.colors.background) + .statusBarsPadding(), + ) { + // 상단 앱바 + CommonTopAppBar(title = "캐릭터 관리", onBackClick = onBackClick) + + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(top = 15.dp), + ) { + // 안내 문구 + Text( + text = "생성 가능한 캐릭터 수는 최대 5개입니다.", + style = CallTheme.typography.caption, + color = CallTheme.colors.gray400, + ) + + Spacer(modifier = Modifier.height(21.dp)) + + // 캐릭터 아이템 목록 + state.aiCharacters.forEach { character -> + CharacterCard( + aiCharacter = character, + onChatHistoryClick = { onIntent(CharacterManagementIntent.ClickChatHistory(character)) }, + onDeleteClick = { onIntent(CharacterManagementIntent.ClickDeleteCharacter(character)) }, + ) + Spacer(modifier = Modifier.height(16.dp)) + } + } + // 캐릭터 추가 버튼 + SecondaryButton( + text = "캐릭터 추가하기", + onClick = { onIntent(CharacterManagementIntent.ClickAddCharacter) }, + containerColor = CallTheme.colors.mainVariant4, + contentColor = CallTheme.colors.white, + modifier = Modifier + .padding(bottom = 24.dp) + .padding(horizontal = 16.dp) + ) + } +} + +// preview용 state +private val previewState = CharacterManagementState( + aiCharacters = listOf( + AiCharacter("1", "김민준", "", true, "2025.05.26", "35일 째", "오늘 21:42", "아이스크림을 커피보다 좋아해요."), + AiCharacter("2", "박동휘", "", false, "2025.06.19", "11일 째", "6/29 12:30", "운동을 좋아해요."), + AiCharacter("3", "정유나", "", false, "2025.06.25", "5일 째", "6/28 09:04", "독서를 좋아해요."), + ), +) + +@Preview(showBackground = true) +@Composable +private fun CharacterManagementScreenPreview() { + CallFromAiTheme { + CharacterManagementScreenContent( + state = previewState, + onIntent = {}, + onBackClick = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ChatHistoryPopupPreview() { + CallFromAiTheme { + CharacterManagementScreenContent( + state = previewState, + onIntent = {}, + onBackClick = {}, + ) + OneButtonPopup( + label = "채팅 기록 보기", + description = buildAnnotatedString { append("아이스티를 커피보다 좋아하며, 늦은 저녁 시간에 대화하는 것을 선호해요. 디자인과 여행 이야기에 관심이 많고, 반존대를 편안하게 느껴요. ") }, + buttonText = "확인", + onButtonClick = {}, + onDismissRequest = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun DeleteConfirmPopupPreview() { + CallFromAiTheme { + CharacterManagementScreenContent( + state = previewState, + onIntent = {}, + onBackClick = {}, + ) + TwoButtonPopup( + label = "캐릭터 영구 삭제", + title = "동휘와 헤어지시겠습니까?", + description = buildAnnotatedString { append("데이터 복구 및 재사용 불가합니다.") }, + positiveText = "삭제", + negativeText = "취소", + onPositiveClick = {}, + onNegativeClick = {}, + onDismissRequest = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun MainDeleteBlockedPopupPreview() { + CallFromAiTheme { + CharacterManagementScreenContent( + state = previewState, + onIntent = {}, + onBackClick = {}, + ) + OneButtonPopup( + label = "캐릭터 영구 삭제", + title = "현재 메인으로 선택된\n연인은 삭제가 불가능합니다.", + buttonText = "확인", + onButtonClick = {}, + onDismissRequest = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun AddBlockedPopupPreview() { + CallFromAiTheme { + CharacterManagementScreenContent( + state = previewState, + onIntent = {}, + onBackClick = {}, + ) + OneButtonPopup( + label = "캐릭터 추가하기", + title = "아직 캐릭터를\n추가할 수 없어요", + description = buildAnnotatedString { + append("새로운 캐릭터를 만든 후 ") + withStyle(SpanStyle(color = CallTheme.colors.mainVariant1)) { + append("24시간") + } + append("이 지나면\n또 다른 캐릭터를 만들 수 있어요") + }, + buttonText = "확인", + onButtonClick = {}, + onDismissRequest = {}, + ) + } +} \ No newline at end of file diff --git a/feature/mypage/impl/src/main/java/kr/co/call/impl/screen/MyPageScreen.kt b/feature/mypage/impl/src/main/java/kr/co/call/impl/screen/MyPageScreen.kt index 9037569..cc5ea4f 100644 --- a/feature/mypage/impl/src/main/java/kr/co/call/impl/screen/MyPageScreen.kt +++ b/feature/mypage/impl/src/main/java/kr/co/call/impl/screen/MyPageScreen.kt @@ -42,6 +42,7 @@ fun MyPageScreen( viewModel: MyPageViewModel = hiltViewModel(), navigateToFaq: () -> Unit, navigateToTerms: () -> Unit, + onNavigateToCharacterManagement: () -> Unit = {}, ) { val state by viewModel.collectAsState() var showLogoutDialog by remember { mutableStateOf(false) } @@ -54,7 +55,7 @@ fun MyPageScreen( is MyPageSideEffect.NavigateToChargeTicket -> { showComingSoon = true } is MyPageSideEffect.NavigateToPurchaseTicket -> { showComingSoon = true } is MyPageSideEffect.NavigateToTicketHistory -> { showComingSoon = true } - is MyPageSideEffect.NavigateToCharacterManagement -> { /* TODO */ } + is MyPageSideEffect.NavigateToCharacterManagement -> onNavigateToCharacterManagement() is MyPageSideEffect.NavigateToFaq -> { navigateToFaq() } is MyPageSideEffect.NavigateToInquiry -> { showComingSoon = true } is MyPageSideEffect.NavigateToTerms -> { navigateToTerms() } diff --git a/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/CharacterManagementIntent.kt b/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/CharacterManagementIntent.kt new file mode 100644 index 0000000..f0b51e4 --- /dev/null +++ b/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/CharacterManagementIntent.kt @@ -0,0 +1,10 @@ +package kr.co.call.impl.viewmodel + +import kr.co.call.domain.model.mypage.AiCharacter + +sealed interface CharacterManagementIntent { + data class ClickChatHistory(val aiCharacter: AiCharacter) : CharacterManagementIntent + data class ClickDeleteCharacter(val aiCharacter: AiCharacter) : CharacterManagementIntent + data class ConfirmDeleteCharacter(val aiCharacter: AiCharacter) : CharacterManagementIntent + data object ClickAddCharacter : CharacterManagementIntent +} \ No newline at end of file diff --git a/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/CharacterManagementSideEffect.kt b/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/CharacterManagementSideEffect.kt new file mode 100644 index 0000000..a1e51fc --- /dev/null +++ b/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/CharacterManagementSideEffect.kt @@ -0,0 +1,11 @@ +package kr.co.call.impl.viewmodel + +import kr.co.call.domain.model.mypage.AiCharacter + +sealed interface CharacterManagementSideEffect { + data class ShowChatHistorySummary(val aiCharacter: AiCharacter) : CharacterManagementSideEffect + data class ShowDeleteConfirmDialog(val aiCharacter: AiCharacter) : CharacterManagementSideEffect + data object ShowMainCharacterDeleteBlocked : CharacterManagementSideEffect + data object ShowAddCharacterBlocked : CharacterManagementSideEffect + data object NavigateToAddCharacter : CharacterManagementSideEffect +} \ No newline at end of file diff --git a/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/CharacterManagementState.kt b/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/CharacterManagementState.kt new file mode 100644 index 0000000..276d0a6 --- /dev/null +++ b/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/CharacterManagementState.kt @@ -0,0 +1,9 @@ +package kr.co.call.impl.viewmodel + +import kr.co.call.domain.model.mypage.AiCharacter +import kr.co.call.domain.util.LoadStatus + +data class CharacterManagementState( + val aiCharacters: List = emptyList(), + val loadStatus: LoadStatus = LoadStatus.Idle, +) \ No newline at end of file diff --git a/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/CharacterManagementViewModel.kt b/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/CharacterManagementViewModel.kt new file mode 100644 index 0000000..347cf06 --- /dev/null +++ b/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/CharacterManagementViewModel.kt @@ -0,0 +1,91 @@ +package kr.co.call.impl.viewmodel + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kr.co.call.domain.model.mypage.AiCharacter +import kr.co.call.domain.repository.AICharacterRepository +import kr.co.call.domain.util.LoadStatus +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container +import java.util.concurrent.CancellationException +import javax.inject.Inject + +@HiltViewModel +class CharacterManagementViewModel @Inject constructor( + private val aiCharacterRepository: AICharacterRepository, +) : ViewModel(), ContainerHost { + + override val container: Container = container( + initialState = CharacterManagementState() + ) { + loadCharacters() + } + + fun handleIntent(userIntent: CharacterManagementIntent) { + when (userIntent) { + is CharacterManagementIntent.ClickChatHistory -> showChatHistory(userIntent.aiCharacter) + is CharacterManagementIntent.ClickDeleteCharacter -> handleDeleteClick(userIntent.aiCharacter) + is CharacterManagementIntent.ConfirmDeleteCharacter -> deleteCharacter(userIntent.aiCharacter.id) + is CharacterManagementIntent.ClickAddCharacter -> checkAddCharacter() + } + } + + private fun loadCharacters() = intent { + reduce { state.copy(loadStatus = LoadStatus.Loading) } + + aiCharacterRepository.getCharacters() + .onSuccess { characters -> + reduce { state.copy(aiCharacters = characters, loadStatus = LoadStatus.Idle) } + } + .onFailure { e -> + if (e is CancellationException) throw e + reduce { state.copy(loadStatus = LoadStatus.Error(e.message ?: "캐릭터 목록 불러오기 실패")) } + } + } + + private fun showChatHistory(aiCharacter: AiCharacter) = intent { + postSideEffect(CharacterManagementSideEffect.ShowChatHistorySummary(aiCharacter)) + } + + private fun handleDeleteClick(aiCharacter: AiCharacter) = intent { + if (aiCharacter.isMain) { + postSideEffect(CharacterManagementSideEffect.ShowMainCharacterDeleteBlocked) + } else { + postSideEffect(CharacterManagementSideEffect.ShowDeleteConfirmDialog(aiCharacter)) + } + } + + private fun deleteCharacter(characterId: String) = intent { + val targetCharacter = state.aiCharacters.find { it.id == characterId } + + if (targetCharacter?.isMain == true) { + postSideEffect(CharacterManagementSideEffect.ShowMainCharacterDeleteBlocked) + return@intent + } + + aiCharacterRepository.deleteCharacter(characterId) + .onSuccess { + reduce { state.copy(aiCharacters = state.aiCharacters.filterNot { it.id == characterId }) } + } + .onFailure { e -> + if (e is CancellationException) throw e + // TODO 삭제 실패 시 처리 + } + } + + private fun checkAddCharacter() = intent { + aiCharacterRepository.canAddCharacter() + .onSuccess { canAdd -> + if (canAdd) { + postSideEffect(CharacterManagementSideEffect.NavigateToAddCharacter) + } else { + postSideEffect(CharacterManagementSideEffect.ShowAddCharacterBlocked) + } + } + .onFailure { e -> + if (e is CancellationException) throw e + postSideEffect(CharacterManagementSideEffect.ShowAddCharacterBlocked) + } + } +} \ No newline at end of file