Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.texthip.thip.data.model.rooms.response

import kotlinx.serialization.Serializable

@Serializable
data class RoomsAiReviewResponse(
val content: String,
val count: Int
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.texthip.thip.data.model.rooms.response

import kotlinx.serialization.Serializable

@Serializable
data class RoomsAiUsageResponse(
val recordReviewCount: Int,
val recordCount: Int
)
Comment on lines +6 to +9
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

디코딩 내구성 강화: 기본값과(필요 시) @SerialName 권장

백엔드 필드 변화/누락 시 디코딩 실패를 피하려면 기본값을 두는 편이 안전합니다. 또한 서버 응답 키가 스네이크 케이스라면 @SerialName 매핑이 필요합니다.

다음 변경을 고려해주세요:

 @Serializable
 data class RoomsAiUsageResponse(
-    val recordReviewCount: Int,
-    val recordCount: Int
+    // 서버가 snake_case를 반환한다면 아래 주석을 해제하고 키를 맞춰주세요.
+    // @SerialName("record_review_count")
+    val recordReviewCount: Int = 0,
+    // @SerialName("record_count")
+    val recordCount: Int = 0
 )

프로젝트 내 직렬화 관례 확인:


🏁 Script executed:

#!/bin/bash
# @SerialName 사용 유무와 관례 확인
rg -nP --type=kotlin -C2 '@SerialName\('
rg -nP --type=kotlin -C2 '\bRoomsAiUsageResponse\b|recordReviewCount|record_count'

Length of output: 73002


@SerialName 및 기본값 추가 필수

프로젝트 전역 관례상 모든 응답 DTO는 @SerialName 어노테이션을 사용합니다. 현재 RoomsAiUsageResponse는 이를 누락했으며, 백엔드 필드 누락 시 디코딩 실패를 방지하려면 기본값도 필요합니다.

다음 변경을 적용하세요:

 @Serializable
 data class RoomsAiUsageResponse(
-    val recordReviewCount: Int,
-    val recordCount: Int
+    @SerialName("record_review_count")
+    val recordReviewCount: Int = 0,
+    @SerialName("record_count")
+    val recordCount: Int = 0
 )

주의: 서버 응답의 실제 필드명(snake_case 또는 camelCase)을 확인하고 @SerialName 값을 맞춰주세요.

🤖 Prompt for AI Agents
In
app/src/main/java/com/texthip/thip/data/model/rooms/response/RoomsAiUsageResponse.kt
around lines 6 to 9, the data class is missing @SerialName annotations and
default values required by project conventions; add
kotlinx.serialization.SerialName to each property matching the server field
names (verify snake_case vs camelCase) and give each property a safe default
(e.g., 0) so decoding won’t fail if fields are absent, and ensure the file
imports kotlinx.serialization.SerialName.

Original file line number Diff line number Diff line change
Expand Up @@ -360,4 +360,20 @@ class RoomsRepository @Inject constructor(
)
).handleBaseResponse().getOrThrow()
}

suspend fun getRoomsAiUsage(
roomId: Int,
) = runCatching {
roomsService.getRoomsAiUsage(
roomId = roomId,
).handleBaseResponse().getOrThrow()
}

suspend fun postRoomsAiReview(
roomId: Int,
) = runCatching {
roomsService.postRoomsAiReview(
roomId = roomId,
).handleBaseResponse().getOrThrow()
}
}
12 changes: 12 additions & 0 deletions app/src/main/java/com/texthip/thip/data/service/RoomsService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import com.texthip.thip.data.model.rooms.response.RoomJoinResponse
import com.texthip.thip.data.model.rooms.response.RoomMainList
import com.texthip.thip.data.model.rooms.response.RoomRecruitingResponse
import com.texthip.thip.data.model.rooms.response.RoomSecretRoomResponse
import com.texthip.thip.data.model.rooms.response.RoomsAiReviewResponse
import com.texthip.thip.data.model.rooms.response.RoomsAiUsageResponse
import com.texthip.thip.data.model.rooms.response.RoomsBookPageResponse
import com.texthip.thip.data.model.rooms.response.RoomsCreateDailyGreetingResponse
import com.texthip.thip.data.model.rooms.response.RoomsCreateVoteResponse
Expand Down Expand Up @@ -215,4 +217,14 @@ interface RoomsService {
@Path("voteId") voteId: Int,
@Body request: RoomsPatchVoteRequest
): BaseResponse<RoomsPatchVoteResponse>

@GET("rooms/{roomId}/users/ai-usage")
suspend fun getRoomsAiUsage(
@Path("roomId") roomId: Int
): BaseResponse<RoomsAiUsageResponse>

@POST("rooms/{roomId}/record/ai-review")
suspend fun postRoomsAiReview(
@Path("roomId") roomId: Int
): BaseResponse<RoomsAiReviewResponse>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
package com.texthip.thip.ui.group.note.screen

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.texthip.thip.R
import com.texthip.thip.ui.common.modal.DialogPopup
import com.texthip.thip.ui.common.modal.ToastWithDate
import com.texthip.thip.ui.common.topappbar.DefaultTopAppBar
import com.texthip.thip.ui.group.note.viewmodel.GroupNoteAiUiState
import com.texthip.thip.ui.group.note.viewmodel.GroupNoteAiViewModel
import com.texthip.thip.ui.theme.ThipTheme
import com.texthip.thip.ui.theme.ThipTheme.colors
import com.texthip.thip.ui.theme.ThipTheme.typography
import kotlinx.coroutines.delay

@Composable
fun GroupNoteAiScreen(
roomId: Int,
onBackClick: () -> Unit,
viewModel: GroupNoteAiViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val clipboardManager = LocalClipboardManager.current

var showToast by remember { mutableStateOf(false) }
var showExitDialog by remember { mutableStateOf(false) }

LaunchedEffect(key1 = roomId) {
viewModel.generateAiReview(roomId)
}

LaunchedEffect(showToast) {
if (showToast) {
delay(3000L)
showToast = false
}
}

GroupNoteAiContent(
uiState = uiState,
showToast = showToast,
showExitDialog = showExitDialog,
onBackClick = { showExitDialog = true },
onCopyClick = { text ->
clipboardManager.setText(AnnotatedString(text))
showToast = true
},
onConfirmExit = onBackClick,
onDismissExitDialog = { showExitDialog = false }
)
}

@Composable
fun GroupNoteAiContent(
uiState: GroupNoteAiUiState,
showToast: Boolean = false,
showExitDialog: Boolean = false,
onBackClick: () -> Unit,
onCopyClick: (String) -> Unit,
onConfirmExit: () -> Unit,
onDismissExitDialog: () -> Unit
) {
val isOverlayVisible = showExitDialog

Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.then(if (isOverlayVisible) Modifier.blur(5.dp) else Modifier)
) {
DefaultTopAppBar(
title = stringResource(R.string.ai_book_review_title),
onLeftClick = onBackClick
)

Box(modifier = Modifier.fillMaxSize()) {
if (uiState.isLoading) {
// 로딩 중
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(20.dp))
Text(
text = stringResource(R.string.ai_review_loading),
style = typography.smalltitle_sb600_s18_h24,
color = colors.White,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.ai_review_loading_subtext),
style = typography.copy_r400_s14,
color = colors.Grey,
textAlign = TextAlign.Center
)
}
} else if (uiState.aiReviewText != null) {
// 로딩 완료
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(start = 26.dp, end = 26.dp, top = 10.dp, bottom = 50.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_information),
contentDescription = "Done Icon",
tint = Color.Unspecified,
modifier = Modifier.align(Alignment.CenterVertically)
)

Text(
text = stringResource(R.string.ai_review_done_info),
style = typography.info_r400_s12,
color = colors.Grey01
)
}
Spacer(modifier = Modifier.height(10.dp))
Text(
text = uiState.aiReviewText,
style = typography.feedcopy_r400_s14_h20,
color = colors.White
)
Spacer(modifier = Modifier.height(24.dp))
}

Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(50.dp)
.background(colors.Purple)
.clickable { onCopyClick(uiState.aiReviewText) },
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.copy_to_clipboard),
style = typography.smalltitle_sb600_s18_h24,
color = colors.White
)
}
} else if (uiState.error != null) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = uiState.error,
style = typography.copy_r400_s14,
color = colors.Grey,
textAlign = TextAlign.Center
)
}
}
}
}

if (showExitDialog) {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
DialogPopup(
title = stringResource(R.string.ai_review_dialog_title),
description = stringResource(R.string.ai_review_exit_dialog_description),
onConfirm = onConfirmExit,
onCancel = onDismissExitDialog
)
}
}

AnimatedVisibility(
visible = showToast,
enter = slideInVertically(
initialOffsetY = { -it },
animationSpec = tween(durationMillis = 2000)
),
exit = slideOutVertically(
targetOffsetY = { -it },
animationSpec = tween(durationMillis = 2000)
),
modifier = Modifier
.align(Alignment.TopCenter)
.padding(horizontal = 20.dp, vertical = 16.dp)
.zIndex(2f)
) {
ToastWithDate(
message = stringResource(R.string.copy_to_clipboard_done)
)
}
}
}

@Preview(showBackground = true)
@Composable
private fun GroupNoteAiScreenLoadingPreview() {
ThipTheme {
GroupNoteAiContent(
uiState = GroupNoteAiUiState(isLoading = true),
onBackClick = {},
onCopyClick = {},
onConfirmExit = {},
onDismissExitDialog = {}
)
}
}

@Preview(showBackground = true)
@Composable
private fun GroupNoteAiScreenDonePreview() {
ThipTheme {
GroupNoteAiContent(
uiState = GroupNoteAiUiState(isLoading = false, aiReviewText = "레이 커즈와일의 마침내 특이점이 시작된다는 읽는 내내 머릿속이 폭발하는 느낌이었다. 인공지능, 나노기술, 생명공학이 동시에 발전해서 결국 인간의 지능과 기계를 융합하는 시대가 온다는 주장인데, 솔직히 처음엔 SF소설 같은 이야기로 느껴졌다."),
onBackClick = {},
onCopyClick = {},
onConfirmExit = {},
onDismissExitDialog = {}
)
}
}
Loading