diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 85cfe743..49ce842f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -92,6 +92,7 @@ dependencies { implementation(project(":data")) implementation(project(":core:designsystem")) + implementation(project(":presentation:ai_conversation")) implementation(project(":presentation:news")) implementation(project(":presentation:notice")) implementation(project(":presentation:learning")) diff --git a/app/src/main/java/com/saegil/android/MainActivity.kt b/app/src/main/java/com/saegil/android/MainActivity.kt index 421eea85..aae2ff15 100644 --- a/app/src/main/java/com/saegil/android/MainActivity.kt +++ b/app/src/main/java/com/saegil/android/MainActivity.kt @@ -45,10 +45,11 @@ fun MainScreen() { Screen.LogList.route, Screen.Log.route, ) || listOf( - Screen.Learning.route, +// Screen.Learning.route, Screen.Log.route, Screen.LogList.route, - ).any { prefix -> currentRoute?.startsWith("$prefix/") == true } + Screen.AiConversation.route, + ).any { prefix -> currentRoute?.startsWith("$prefix/") == true } Scaffold( topBar = { diff --git a/app/src/main/java/com/saegil/android/navigation/NavGraph.kt b/app/src/main/java/com/saegil/android/navigation/NavGraph.kt index 7575c5d3..629a2468 100644 --- a/app/src/main/java/com/saegil/android/navigation/NavGraph.kt +++ b/app/src/main/java/com/saegil/android/navigation/NavGraph.kt @@ -1,5 +1,6 @@ package com.saegil.android.navigation +import android.util.Log import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -18,6 +19,10 @@ import com.saegil.notice.notice.NoticeScreen import com.saegil.onboarding.OnboardingScreen import com.saegil.splash.SplashScreen import androidx.core.net.toUri +import com.saegil.ai_conversation.SaegilCharacter +import com.saegil.ai_conversation.aiconversation.AiConversationScreen +import com.saegil.ai_conversation.aiconversationlist.AiConversationListScreen +import com.saegil.learning.learning.components.CharacterEmotion import com.saegil.news.news.NewsScreen import com.saegil.news.newsquiz.NewsQuizScreen @@ -27,6 +32,28 @@ fun NavGraph(navController: NavHostController, modifier: Modifier) { val context = LocalContext.current NavHost(navController = navController, startDestination = Screen.Splash.route) { + + composable(Screen.AiConversation.route) { + AiConversationListScreen( + modifier = modifier, + onCharacterClick = { character -> + navController.navigate("${Screen.AiConversation.route}/$character") + Log.d("character", character) + } + ) + } + composable( + route = "${Screen.AiConversation.route}/{characterString}", + arguments = listOf(navArgument("characterString") { type = NavType.StringType }) + ) { backStackEntry -> + val characterString = backStackEntry.arguments?.getString("characterString") + val character = runCatching { SaegilCharacter.valueOf(characterString ?: "") }.getOrNull() + + AiConversationScreen(character = character, + navigateToAiConversationList = { navController.popBackStack() } + ) + } + composable(Screen.Learning.route) { LearningListScreen( modifier = modifier, @@ -114,7 +141,7 @@ fun NavGraph(navController: NavHostController, modifier: Modifier) { composable(Screen.Onboarding.route) { OnboardingScreen( navigateToMain = { - navController.navigate(Screen.Learning.route) { + navController.navigate(Screen.AiConversation.route) { popUpTo(Screen.Splash.route) { inclusive = true } } } @@ -128,7 +155,7 @@ fun NavGraph(navController: NavHostController, modifier: Modifier) { } }, navigateToMain = { - navController.navigate(Screen.Learning.route) { + navController.navigate(Screen.AiConversation.route) { popUpTo(Screen.Splash.route) { inclusive = true } } }, diff --git a/app/src/main/java/com/saegil/android/navigation/Screen.kt b/app/src/main/java/com/saegil/android/navigation/Screen.kt index d75da454..c7dcb459 100644 --- a/app/src/main/java/com/saegil/android/navigation/Screen.kt +++ b/app/src/main/java/com/saegil/android/navigation/Screen.kt @@ -4,6 +4,8 @@ import androidx.annotation.DrawableRes import com.saegil.android.R sealed class Screen(val route: String, @DrawableRes val icon: Int?, val label: String) { + object AiConversation : Screen("ai_conversation", R.drawable.ic_pencil, "AI 전화 회화") + object Learning : Screen("learning", R.drawable.ic_pencil, "학습") object Announcement : Screen("announcement", R.drawable.ic_announcement, "공지사항") object Map : Screen("map", R.drawable.ic_location, "지도") @@ -16,6 +18,6 @@ sealed class Screen(val route: String, @DrawableRes val icon: Int?, val label: S data object Quiz : Screen("quiz", null, "퀴즈") companion object { - val items = listOf(Learning, Announcement, News, MyPage) + val items = listOf(AiConversation, Announcement, News, MyPage) } } \ No newline at end of file diff --git a/core/designsystem/src/main/res/drawable/img_btn_end_call.png b/core/designsystem/src/main/res/drawable/img_btn_end_call.png new file mode 100644 index 00000000..c06f7c69 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_btn_end_call.png differ diff --git a/core/designsystem/src/main/res/drawable/img_gildong.png b/core/designsystem/src/main/res/drawable/img_gildong.png new file mode 100644 index 00000000..3e1c8403 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_gildong.png differ diff --git a/core/designsystem/src/main/res/drawable/img_saerom.png b/core/designsystem/src/main/res/drawable/img_saerom.png new file mode 100644 index 00000000..329d7956 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_saerom.png differ diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 5441fa19..b239eb05 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -22,6 +22,7 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") buildConfigField("String", "BASE_URL", "\"${properties.getProperty("base_url")}\"") + buildConfigField("String", "OPEN_AI_API_KEY", "\"${properties.getProperty("openai_api_key")}\"") } buildTypes { @@ -105,4 +106,10 @@ dependencies { implementation(libs.androidx.datastore.preferences) implementation(project(":domain"))//클린아키텍처 도메인 의존 + + implementation("io.ktor:ktor-client-websockets:2.3.5") + implementation("io.ktor:ktor-client-content-negotiation:2.3.5") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") + + } \ No newline at end of file diff --git a/data/src/main/java/com/saegil/data/di/DataModule.kt b/data/src/main/java/com/saegil/data/di/DataModule.kt index 0a52cab7..ab28a696 100644 --- a/data/src/main/java/com/saegil/data/di/DataModule.kt +++ b/data/src/main/java/com/saegil/data/di/DataModule.kt @@ -11,6 +11,7 @@ import com.saegil.data.remote.MapService import com.saegil.data.remote.NewsService import com.saegil.data.remote.OAuthService import com.saegil.data.remote.QuizService +import com.saegil.data.remote.RealTimeService import com.saegil.data.remote.ScenarioService import com.saegil.data.remote.SimulationLogService import com.saegil.data.remote.TextToSpeechService @@ -21,6 +22,7 @@ import com.saegil.data.repository.MapRepositoryImpl import com.saegil.data.repository.NewsRepositoryImpl import com.saegil.data.repository.OAuthRepositoryImpl import com.saegil.data.repository.QuizRepositoryImpl +import com.saegil.data.repository.RealTimeRepositoryImpl import com.saegil.data.repository.ScenarioRepositoryImpl import com.saegil.data.repository.SimulationLogRepositoryImpl import com.saegil.data.repository.TextToSpeechRepositoryImpl @@ -32,6 +34,7 @@ import com.saegil.domain.repository.MapRepository import com.saegil.domain.repository.NewsRepository import com.saegil.domain.repository.OAuthRepository import com.saegil.domain.repository.QuizRepository +import com.saegil.domain.repository.RealTimeRepository import com.saegil.domain.repository.ScenarioRepository import com.saegil.domain.repository.SimulationLogRepository import com.saegil.domain.repository.TextToSpeechRepository @@ -110,6 +113,12 @@ object DataModule { return UserInfoRepositoryImpl(userInfoService) } + @Provides + @Singleton + fun provideRealTimeRepository(realTimeService: RealTimeService): RealTimeRepository { + return RealTimeRepositoryImpl(realTimeService) + } + @Provides @Singleton fun provideUserPreferenceRepository(interestService: InterestService): UserTopicRepository { diff --git a/data/src/main/java/com/saegil/data/di/NetworkModule.kt b/data/src/main/java/com/saegil/data/di/NetworkModule.kt index d9cffd28..7aa13a5e 100644 --- a/data/src/main/java/com/saegil/data/di/NetworkModule.kt +++ b/data/src/main/java/com/saegil/data/di/NetworkModule.kt @@ -7,6 +7,7 @@ import com.saegil.data.remote.AssistantServiceImpl import com.saegil.data.remote.FeedService import com.saegil.data.remote.FeedServiceImpl import com.saegil.data.remote.HttpRoutes.ASSISTANT +import com.saegil.data.remote.HttpRoutes.GET_REALTIME_TOKEN import com.saegil.data.remote.HttpRoutes.NEWS import com.saegil.data.remote.HttpRoutes.NEWS_INTERESTS import com.saegil.data.remote.HttpRoutes.OAUTH_LOGOUT @@ -23,6 +24,8 @@ import com.saegil.data.remote.NewsService import com.saegil.data.remote.NewsServiceImpl import com.saegil.data.remote.OAuthService import com.saegil.data.remote.OAuthServiceImpl +import com.saegil.data.remote.RealTimeService +import com.saegil.data.remote.RealTimeServiceImpl import com.saegil.data.remote.QuizService import com.saegil.data.remote.QuizServiceImpl import com.saegil.data.remote.ScenarioService @@ -39,6 +42,7 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import io.ktor.client.HttpClient import io.ktor.client.engine.android.Android +import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.DefaultRequest import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.logging.LogLevel @@ -60,7 +64,7 @@ object NetworkModule { fun provideHttpClient( tokenDataSource: TokenDataSource ): HttpClient { - return HttpClient(Android) { + return HttpClient(CIO) { install(Logging) { level = LogLevel.ALL logger = Logger.SIMPLE @@ -83,11 +87,13 @@ object NetworkModule { TTS, USER, ASSISTANT, + GET_REALTIME_TOKEN, NEWS_INTERESTS, NEWS ).any { it in path } } } + install(io.ktor.client.plugins.websocket.WebSockets) } } @@ -158,4 +164,10 @@ object NetworkModule { return QuizServiceImpl(client) } + @Provides + @Singleton + fun provideRealTimeService(client: HttpClient): RealTimeService { + return RealTimeServiceImpl(client) + } + } \ No newline at end of file diff --git a/data/src/main/java/com/saegil/data/model/GetRealTimeApiTokenReponse.kt b/data/src/main/java/com/saegil/data/model/GetRealTimeApiTokenReponse.kt new file mode 100644 index 00000000..e3b049a1 --- /dev/null +++ b/data/src/main/java/com/saegil/data/model/GetRealTimeApiTokenReponse.kt @@ -0,0 +1,49 @@ +package com.saegil.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class RealtimeResponse( + val id: String, + val `object`: String, + val model: String, + val modalities: List, + val instructions: String, + val voice: String, + val input_audio_format: String, + val output_audio_format: String, + val input_audio_transcription: InputAudioTranscription, + val turn_detection: String, // null 허용 + val tools: List = emptyList(), + val tool_choice: String, + val temperature: Double, + val speed: Double, + val tracing: String, // "auto" + val max_response_output_tokens: Int, + val client_secret: ClientSecret +){ + fun toDomain(): String { + return client_secret.value + } +} + +@Serializable +data class InputAudioTranscription( + val model: String +) + +//@Serializable +//data class TurnDetection( +// // 현재 예시에선 null이므로 생략 가능. 나중에 구조 생기면 필드 추가 +//) +// +//@Serializable +//data class Tool( +// // 현재 예시에선 빈 객체 리스트, 추후 구조 생기면 필드 추가 +//) + +@Serializable +data class ClientSecret( + val value: String, + val expires_at: Long +) diff --git a/data/src/main/java/com/saegil/data/remote/AssistantService.kt b/data/src/main/java/com/saegil/data/remote/AssistantService.kt index c0e95199..c6131932 100644 --- a/data/src/main/java/com/saegil/data/remote/AssistantService.kt +++ b/data/src/main/java/com/saegil/data/remote/AssistantService.kt @@ -1,5 +1,6 @@ package com.saegil.data.remote +import com.saegil.data.model.RealtimeResponse import com.saegil.data.model.UploadAudioDto import java.io.File @@ -7,4 +8,8 @@ interface AssistantService { suspend fun getAssistant(file: File, threadId: String?, scenarioId: Int): UploadAudioDto + suspend fun realtimeAssistant() + + suspend fun getRealtimeToken(): String? + } \ No newline at end of file diff --git a/data/src/main/java/com/saegil/data/remote/AssistantServiceImpl.kt b/data/src/main/java/com/saegil/data/remote/AssistantServiceImpl.kt index c4e13876..7a0a6ad1 100644 --- a/data/src/main/java/com/saegil/data/remote/AssistantServiceImpl.kt +++ b/data/src/main/java/com/saegil/data/remote/AssistantServiceImpl.kt @@ -1,13 +1,26 @@ package com.saegil.data.remote +import android.util.Log +import com.saegil.data.model.OrganizationDto +import com.saegil.data.model.RealtimeResponse import com.saegil.data.model.UploadAudioDto import io.ktor.client.HttpClient import io.ktor.client.call.body +import io.ktor.client.request.accept import io.ktor.client.request.forms.formData import io.ktor.client.request.forms.submitFormWithBinaryData +import io.ktor.client.request.get +import io.ktor.client.request.headers import io.ktor.client.request.parameter +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType import io.ktor.http.Headers import io.ktor.http.HttpHeaders +import io.ktor.http.URLBuilder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import java.io.File import javax.inject.Inject @@ -15,7 +28,11 @@ class AssistantServiceImpl @Inject constructor( private val client: HttpClient ) : AssistantService { - override suspend fun getAssistant(file: File, threadId: String?, scenarioId: Int): UploadAudioDto { + override suspend fun getAssistant( + file: File, + threadId: String?, + scenarioId: Int + ): UploadAudioDto { val response = client.submitFormWithBinaryData( url = HttpRoutes.ASSISTANT, formData = formData { @@ -31,4 +48,32 @@ class AssistantServiceImpl @Inject constructor( } return response.body() } + + override suspend fun getRealtimeToken(): String? { + + val urlBuilder = URLBuilder(HttpRoutes.GET_REALTIME_TOKEN) + + val response = client.get(urlBuilder.build()) { + headers { + accept(ContentType.Application.Json) + } + } + val responseBody = response.bodyAsText() + val json = Json.parseToJsonElement(responseBody) + + // JSON 구조에서 client_secret.value 접근 + val value = json + .jsonObject["client_secret"] + ?.jsonObject + ?.get("value") + ?.jsonPrimitive + ?.contentOrNull + + Log.d("value", value.toString()) + return value.toString() + } + + override suspend fun realtimeAssistant() { + TODO("Not yet implemented") + } } diff --git a/data/src/main/java/com/saegil/data/remote/HttpRoutes.kt b/data/src/main/java/com/saegil/data/remote/HttpRoutes.kt index c297bfe3..b916cfe4 100644 --- a/data/src/main/java/com/saegil/data/remote/HttpRoutes.kt +++ b/data/src/main/java/com/saegil/data/remote/HttpRoutes.kt @@ -24,6 +24,8 @@ object HttpRoutes { const val ASSISTANT = "$BASE_URL/api/v2/llm/assistant/upload" // 음성 파일로부터 Assistant 응답 가져오기 + const val GET_REALTIME_TOKEN = "$BASE_URL/api/realtime/token" + const val TTS = "$BASE_URL/api/v1/llm/tts" const val SIMULATION_LOG = "$BASE_URL/api/v1/simulations" diff --git a/data/src/main/java/com/saegil/data/remote/RealTimeMessageSender.kt b/data/src/main/java/com/saegil/data/remote/RealTimeMessageSender.kt new file mode 100644 index 00000000..f354a945 --- /dev/null +++ b/data/src/main/java/com/saegil/data/remote/RealTimeMessageSender.kt @@ -0,0 +1,58 @@ +package com.saegil.data.remote + +import android.os.Build +import androidx.annotation.RequiresApi +import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession +import io.ktor.websocket.Frame +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import java.util.Base64 + +object RealtimeMessageSender { + + suspend fun sendUserTextMessage( + client: DefaultClientWebSocketSession, + message: String + ) { + val createMessage = buildJsonObject { + put("type", "conversation.item.create") + putJsonObject("item") { + put("type", "message") + put("role", "user") + putJsonArray("content") { + addJsonObject { + put("type", "input_text") + put("text", message) + } + } + } + } + + val createResponse = buildJsonObject { + put("type", "response.create") + } + + client.send(Frame.Text(createMessage.toString())) + client.send(Frame.Text(createResponse.toString())) + } + + @RequiresApi(Build.VERSION_CODES.O) + suspend fun sendPcmAudio(client: DefaultClientWebSocketSession, pcm: ByteArray) { + val base64Audio = Base64.getEncoder().encodeToString(pcm) + val appendFrame = buildJsonObject { + put("type", "input_audio_buffer.append") + put("audio", base64Audio) + } + client.send(Frame.Text(appendFrame.toString())) + } + + suspend fun commitAudio(client: DefaultClientWebSocketSession) { + val commitFrame = buildJsonObject { + put("type", "input_audio_buffer.commit") + } + client.send(Frame.Text(commitFrame.toString())) + } +} diff --git a/data/src/main/java/com/saegil/data/remote/RealTimeService.kt b/data/src/main/java/com/saegil/data/remote/RealTimeService.kt new file mode 100644 index 00000000..ef73435a --- /dev/null +++ b/data/src/main/java/com/saegil/data/remote/RealTimeService.kt @@ -0,0 +1,11 @@ +package com.saegil.data.remote + +interface RealTimeService { + suspend fun connectToRealtimeSession(secret: String) + + suspend fun sendPcm(pcm: ByteArray) + + suspend fun commitAudio() + + suspend fun disconnect() +} diff --git a/data/src/main/java/com/saegil/data/remote/RealTimeServiceImpl.kt b/data/src/main/java/com/saegil/data/remote/RealTimeServiceImpl.kt new file mode 100644 index 00000000..c81cd4c8 --- /dev/null +++ b/data/src/main/java/com/saegil/data/remote/RealTimeServiceImpl.kt @@ -0,0 +1,243 @@ +package com.saegil.data.remote + +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioRecord +import android.media.AudioTrack +import android.media.MediaRecorder +import android.os.Build +import androidx.annotation.RequiresApi +import com.saegil.data.BuildConfig +import io.ktor.client.HttpClient +import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession +import io.ktor.client.plugins.websocket.webSocket +import io.ktor.client.request.header +import io.ktor.client.request.url +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.util.Base64 + +class RealTimeServiceImpl( + private val client: HttpClient +) : RealTimeService { + + private var session: DefaultClientWebSocketSession? = null + private var isStreaming = false + + @RequiresApi(Build.VERSION_CODES.O) + override suspend fun connectToRealtimeSession(secret: String) { + try { + client.webSocket( + method = HttpMethod.Get, + host = "api.openai.com", + path = "/v1/realtime/sessions/$secret", + request = { + url("wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-12-17&modalities=audio") + header(HttpHeaders.Authorization, "Bearer ${BuildConfig.OPEN_AI_API_KEY}") + header("OpenAI-Beta", "realtime=v1") + } + ) { + println("✅ WebSocket connected") + session = this + isStreaming = true + + launch(Dispatchers.IO) { + startAudioStreaming(this@webSocket) + } + + for (frame in incoming) { + if (frame is Frame.Text) { + val json = frame.readText() + println("📨 Received: $json") + + val jsonObj = Json.parseToJsonElement(json).jsonObject + when (jsonObj["type"]?.jsonPrimitive?.content) { + "session.created" -> { + // Handle session creation confirmation. + // You might want to store the session ID here. + val sessionId = jsonObj["session"]?.jsonObject?.get("id")?.jsonPrimitive?.content + println("Session created with ID: $sessionId") + } + // This is the crucial part: handling the audio delta messages + "response.audio.delta" -> { + // Extract the "delta" field which contains the Base64 encoded audio bytes + val base64Audio = jsonObj["delta"]?.jsonPrimitive?.content ?: "" + if (base64Audio.isNotEmpty()) { + try { + // Decode the Base64 string to a ByteArray + val audioBytes = Base64.getDecoder().decode(base64Audio) + // Write the audio bytes to the AudioTrack for playback + playAudio(audioBytes) + } catch (e: IllegalArgumentException) { + println("❌ Base64 decoding error: ${e.message}") + } + } + } + "response.completed" -> { + // The AI's response has completed. + // You might want to signal the end of speech here, + // but keep the AudioTrack playing until its buffer is empty. + println("AI response completed.") + } + // Handle other message types if necessary (e.g., input_audio_buffer.speech_started/stopped) + else -> { + // println("Received message of type: ${jsonObj["type"]?.jsonPrimitive?.content}") + } +// "audio" -> { +// val base64Audio = jsonObj["data"]?.jsonPrimitive?.content ?: "" +// val audioBytes = Base64.getDecoder().decode(base64Audio) +// playAudio(audioBytes) +// } +// else -> { +// // 다른 메시지 타입 처리 (예: session.created, error 등) +//// println("Received message but not audio: $json") +// } + } + } + } + } + } catch (e: Exception) { + println("❌ WebSocket error: ${e.message}") + } finally { + isStreaming = false + } + } + + @RequiresApi(Build.VERSION_CODES.O) + override suspend fun sendPcm(pcm: ByteArray) { + session?.let { + RealtimeMessageSender.sendPcmAudio(it, pcm) + } ?: println("❌ No active WebSocket session to send PCM") + } + + override suspend fun commitAudio() { + session?.let { + RealtimeMessageSender.commitAudio(it) + } ?: println("❌ No active WebSocket session to commit audio") + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun startAudioStreaming(ws: DefaultClientWebSocketSession) { + val recorder = AudioRecord( + MediaRecorder.AudioSource.MIC, + SAMPLE_RATE, + CHANNEL_CONFIG, + AUDIO_FORMAT, + BUFFER_SIZE + ) + + if (recorder.state != AudioRecord.STATE_INITIALIZED) { + println("❌ AudioRecord 초기화 실패") + return + } + + val buffer = ByteArray(BUFFER_SIZE) + var appendCount = 0 + + recorder.startRecording() + println("🎙️ Audio recording started") + + while (isStreaming) { + val read = recorder.read(buffer, 0, buffer.size) + if (read > 0) { + val audioChunk = buffer.copyOf(read) + + CoroutineScope(Dispatchers.IO).launch { + RealtimeMessageSender.sendPcmAudio(ws, audioChunk) + } + + appendCount++ + } else { + println("⚠️ Audio read failed or empty (read=$read)") + } + + if (appendCount >= 10) { + CoroutineScope(Dispatchers.IO).launch { + RealtimeMessageSender.commitAudio(ws) + } + appendCount = 0 + } + } + + recorder.stop() + recorder.release() + + CoroutineScope(Dispatchers.IO).launch { + RealtimeMessageSender.commitAudio(ws) + } + + println("🎙️ Audio recording stopped and committed") + } + + private var audioTrack: AudioTrack? = null + private var isAudioPlaying = false + private val audioQueue: ArrayDeque = ArrayDeque() + + private fun initializeAudioTrackIfNeeded() { + if (audioTrack == null) { + audioTrack = AudioTrack( + AudioManager.STREAM_MUSIC, + SAMPLE_RATE, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT, + BUFFER_SIZE, + AudioTrack.MODE_STREAM, + AudioManager.AUDIO_SESSION_ID_GENERATE + ).apply { + play() + println("🔄 AudioTrack initialized") + } + } + } + + private fun playAudio(audio: ByteArray) { + audioQueue.addLast(audio) + println("📥 Queued audio chunk. Queue size: ${audioQueue.size}") + + if (!isAudioPlaying) { + isAudioPlaying = true + CoroutineScope(Dispatchers.IO).launch { + initializeAudioTrackIfNeeded() + + while (audioQueue.isNotEmpty()) { + val chunk = audioQueue.removeFirst() + audioTrack?.write(chunk, 0, chunk.size) + } + + // wait for buffer to play + audioTrack?.flush() + isAudioPlaying = false + println("✅ Finished playing all queued audio.") + } + } + } + + companion object{ + private const val SAMPLE_RATE = 16000 // 16kHz + private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO // 모노 채널 + private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT // 16bit signed PCM + private val BUFFER_SIZE = AudioRecord.getMinBufferSize( + SAMPLE_RATE, + CHANNEL_CONFIG, + AUDIO_FORMAT + ).coerceAtLeast(SAMPLE_RATE * 2) // 1초 분량 or 최소값 이상 + + } + + override suspend fun disconnect() { + isStreaming = false + session?.close() + session = null + println("🔌 WebSocket disconnected") + } + +} diff --git a/data/src/main/java/com/saegil/data/repository/AssistantRepositoryImpl.kt b/data/src/main/java/com/saegil/data/repository/AssistantRepositoryImpl.kt index e8ec59fb..f0cb9613 100644 --- a/data/src/main/java/com/saegil/data/repository/AssistantRepositoryImpl.kt +++ b/data/src/main/java/com/saegil/data/repository/AssistantRepositoryImpl.kt @@ -30,4 +30,8 @@ class AssistantRepositoryImpl( override suspend fun clearThreadId() { threadPreferencesManager.clearThreadId() } + + override suspend fun getRealTimeApiToken() : String?{ + return assistantService.getRealtimeToken() + } } diff --git a/data/src/main/java/com/saegil/data/repository/RealTimeRepositoryImpl.kt b/data/src/main/java/com/saegil/data/repository/RealTimeRepositoryImpl.kt new file mode 100644 index 00000000..e972014c --- /dev/null +++ b/data/src/main/java/com/saegil/data/repository/RealTimeRepositoryImpl.kt @@ -0,0 +1,25 @@ +package com.saegil.data.repository + +import com.saegil.data.remote.RealTimeService +import com.saegil.domain.repository.RealTimeRepository + +class RealTimeRepositoryImpl ( + private val service: RealTimeService +) : RealTimeRepository { + + override suspend fun connect(secret: String) { + service.connectToRealtimeSession(secret) + } + + override suspend fun sendPcm(pcm: ByteArray) { + service.sendPcm(pcm) + } + + override suspend fun commitAudio() { + service.commitAudio() + } + + override suspend fun disconnect() { + service.disconnect() + } +} diff --git a/domain/src/main/java/com/saegil/domain/repository/AssistantRepository.kt b/domain/src/main/java/com/saegil/domain/repository/AssistantRepository.kt index 88943e74..988de254 100644 --- a/domain/src/main/java/com/saegil/domain/repository/AssistantRepository.kt +++ b/domain/src/main/java/com/saegil/domain/repository/AssistantRepository.kt @@ -15,4 +15,6 @@ interface AssistantRepository { suspend fun saveThreadId(threadId: String) fun getThreadId(): Flow suspend fun clearThreadId() + + suspend fun getRealTimeApiToken(): String? } diff --git a/domain/src/main/java/com/saegil/domain/repository/RealTimeRepository.kt b/domain/src/main/java/com/saegil/domain/repository/RealTimeRepository.kt new file mode 100644 index 00000000..0bfa5dc9 --- /dev/null +++ b/domain/src/main/java/com/saegil/domain/repository/RealTimeRepository.kt @@ -0,0 +1,12 @@ +package com.saegil.domain.repository + +interface RealTimeRepository { + suspend fun connect(secret: String) + + suspend fun sendPcm(pcm: ByteArray) + + suspend fun commitAudio() + + suspend fun disconnect() + +} diff --git a/domain/src/main/java/com/saegil/domain/usecase/EndRealtimeChatUseCase.kt b/domain/src/main/java/com/saegil/domain/usecase/EndRealtimeChatUseCase.kt new file mode 100644 index 00000000..0cd28a1b --- /dev/null +++ b/domain/src/main/java/com/saegil/domain/usecase/EndRealtimeChatUseCase.kt @@ -0,0 +1,10 @@ +package com.saegil.domain.usecase + +import com.saegil.domain.repository.RealTimeRepository +import javax.inject.Inject + +class EndRealtimeChatUseCase @Inject constructor( + private val repository: RealTimeRepository +) { + suspend operator fun invoke() = repository.disconnect() +} \ No newline at end of file diff --git a/domain/src/main/java/com/saegil/domain/usecase/GetRealTimeTokenUsecase.kt b/domain/src/main/java/com/saegil/domain/usecase/GetRealTimeTokenUsecase.kt new file mode 100644 index 00000000..b92ad435 --- /dev/null +++ b/domain/src/main/java/com/saegil/domain/usecase/GetRealTimeTokenUsecase.kt @@ -0,0 +1,21 @@ +package com.saegil.domain.usecase + +import android.util.Log +import com.saegil.domain.model.Recruitment +import com.saegil.domain.repository.AssistantRepository +import com.saegil.domain.repository.MapRepository +import kotlinx.coroutines.flow.Flow +import java.lang.reflect.Constructor +import javax.inject.Inject + +class GetRealTimeTokenUsecase @Inject constructor( + private val assistantRepository: AssistantRepository +) { + suspend operator fun invoke(): String { + val token = assistantRepository.getRealTimeApiToken() + Log.d("token", token.toString()) + return token.toString() + } + +} + diff --git a/domain/src/main/java/com/saegil/domain/usecase/StartRealtimeChatUseCase.kt b/domain/src/main/java/com/saegil/domain/usecase/StartRealtimeChatUseCase.kt new file mode 100644 index 00000000..723dbf89 --- /dev/null +++ b/domain/src/main/java/com/saegil/domain/usecase/StartRealtimeChatUseCase.kt @@ -0,0 +1,21 @@ +package com.saegil.domain.usecase + +import com.saegil.domain.repository.RealTimeRepository +import javax.inject.Inject + +class StartRealtimeChatUseCase @Inject constructor( + private val repository: RealTimeRepository +) { + suspend operator fun invoke(clientSecret: String) { + repository.connect(clientSecret) + } + + + suspend fun sendPcm(pcm: ByteArray) { + repository.sendPcm(pcm) + } + + suspend fun commitAudio() { + repository.commitAudio() + } +} diff --git a/presentation/ai_conversation/.gitignore b/presentation/ai_conversation/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/presentation/ai_conversation/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/presentation/ai_conversation/build.gradle.kts b/presentation/ai_conversation/build.gradle.kts new file mode 100644 index 00000000..5c4c1fa8 --- /dev/null +++ b/presentation/ai_conversation/build.gradle.kts @@ -0,0 +1,76 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.devtools.ksp) +} + +android { + namespace = "com.saegil.ai_conversation" + compileSdk = 35 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.androidx.runtime.android) + implementation(libs.androidx.ui.tooling.preview.android) + implementation(libs.androidx.foundation.layout.android) + implementation(project(":core:common")) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + debugImplementation(libs.androidx.ui.tooling) + + //hilt + implementation(libs.hilt.android) + implementation(libs.androidx.hilt.navigation.compose) + ksp(libs.hilt.android.compiler) + + //Coil + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) + + implementation(project(":core:designsystem")) + implementation(project(":domain")) + + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("io.ktor:ktor-client-websockets:2.3.5") + +} \ No newline at end of file diff --git a/presentation/ai_conversation/proguard-rules.pro b/presentation/ai_conversation/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/presentation/ai_conversation/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/presentation/ai_conversation/src/androidTest/java/com/saegil/ai_conversation/ExampleInstrumentedTest.kt b/presentation/ai_conversation/src/androidTest/java/com/saegil/ai_conversation/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..ee704147 --- /dev/null +++ b/presentation/ai_conversation/src/androidTest/java/com/saegil/ai_conversation/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.saegil.ai_conversation + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.saegil.ai_conversation", appContext.packageName) + } +} \ No newline at end of file diff --git a/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/SaegilCharacter.kt b/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/SaegilCharacter.kt new file mode 100644 index 00000000..433d8f54 --- /dev/null +++ b/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/SaegilCharacter.kt @@ -0,0 +1,29 @@ +package com.saegil.ai_conversation + +import com.saegil.ai_conversation.R + +enum class SaegilCharacter( + val img: Int, + val nickname: String, + val gender: String, + val personality: String, + val description: String, + val comment: String, +) { + SAEROM( + R.drawable.img_saerom, + "새롬", + "여", + "ENFP", + "동갑내기 친구", + "동갑내기 친구와 “반말”로 편안하게 대화해보세요!" + ), + GILDONG( + R.drawable.img_gildong, + "길동", + "남", + "INTJ", + "연장자", + "연장자에게는 “존댓말”로 대화하는 걸 잊지마세요!" + ) +} \ No newline at end of file diff --git a/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversation/AiConversationScreen.kt b/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversation/AiConversationScreen.kt new file mode 100644 index 00000000..541ec8af --- /dev/null +++ b/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversation/AiConversationScreen.kt @@ -0,0 +1,168 @@ +package com.saegil.ai_conversation.aiconversation + +import android.Manifest +import android.app.Activity +import android.content.pm.PackageManager +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.saegil.ai_conversation.R +import com.saegil.ai_conversation.SaegilCharacter +import com.saegil.designsystem.theme.SaegilAndroidTheme +import com.saegil.designsystem.theme.body2 +import com.saegil.designsystem.theme.h1 +import com.saegil.designsystem.theme.h3 + +@Composable +fun AiConversationScreen( + character: SaegilCharacter?, + modifier: Modifier = Modifier, + viewModel: AiConversationViewModel = hiltViewModel(), + navigateToAiConversationList: () -> Unit = {}, +) { + + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + InternalAiConversationScreen( + character = character, + state = state, + modifier = modifier, + onStopButtonClick = viewModel::stopChatSession, + navigateToAiConversationList = navigateToAiConversationList, + ) + +} + +@Composable +internal fun InternalAiConversationScreen( + character: SaegilCharacter? = SaegilCharacter.SAEROM, + state: AiConversationState, + modifier: Modifier, + onStopButtonClick: () -> Unit = {}, + navigateToAiConversationList: () -> Unit = {}, +) { + + val context = LocalContext.current + + if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) + != PackageManager.PERMISSION_GRANTED + ) { + + ActivityCompat.requestPermissions( + context as Activity, // 여기가 중요! + arrayOf(Manifest.permission.RECORD_AUDIO), + 1001 + ) + } + Surface( + modifier = modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color(0xFFE7F2FF), // 시작 색상 (연한 하늘색) + Color(0xFFFFFFFF) // 끝 색상 (흰색) + ) + ) + ), + color = Color.Transparent + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + "전화 거는 중...", + style = MaterialTheme.typography.h3, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(18.dp)) + Box( + Modifier.border( + 1.dp, + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(4.dp) // ← 코너를 4dp만큼 둥글게 + ) + ) { + + Text( + character?.comment ?: "", + modifier = Modifier.padding(10.dp), + style = MaterialTheme.typography.body2 + ) + + } + Spacer(modifier = Modifier.height(60.dp)) + + Image( + painterResource(character!!.img), + modifier = Modifier.size(200.dp), + contentDescription = "새롬" + ) + + + Text( + character.nickname, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.h1, + modifier = Modifier.padding(top = 18.dp) + ) + + Spacer(modifier = Modifier.height(120.dp)) + + Image( + painterResource(R.drawable.img_btn_end_call), + modifier = Modifier + .size(96.dp) + .clickable { + onStopButtonClick() + navigateToAiConversationList() + }, + contentDescription = "전화 종료" + ) + } + + + } +} + +@Composable +@Preview(name = "AiConversation") +private fun AiConversationScreenPreview() { + SaegilAndroidTheme { + InternalAiConversationScreen( + character = SaegilCharacter.GILDONG, + state = AiConversationState(), + modifier = Modifier + ) + } +} + diff --git a/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversation/AiConversationViewModel.kt b/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversation/AiConversationViewModel.kt new file mode 100644 index 00000000..9a15480a --- /dev/null +++ b/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversation/AiConversationViewModel.kt @@ -0,0 +1,115 @@ +package com.saegil.ai_conversation.aiconversation + +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.saegil.domain.usecase.EndRealtimeChatUseCase +import com.saegil.domain.usecase.GetRealTimeTokenUsecase +import com.saegil.domain.usecase.StartRealtimeChatUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import javax.inject.Inject +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@HiltViewModel +class AiConversationViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + val startRealtimeChatUseCase: StartRealtimeChatUseCase, + private val getRealtimeTokenUseCase: GetRealTimeTokenUsecase, + private val disconnectRealtimeUseCase: EndRealtimeChatUseCase + +) : ViewModel() { + + private val _stateFlow: MutableStateFlow = + MutableStateFlow(AiConversationState()) + + val stateFlow: StateFlow = _stateFlow.asStateFlow() + + init { + viewModelScope.launch { + val token = onRequestToken() + startChatSession(token) + } + } + suspend fun onRequestToken(): String { + Log.d("AiConversationListViewModel", "onRequestToken called") + val result = getRealtimeTokenUseCase() + Log.d("result", result) + return result + } + + fun startChatSession(secret: String) { + viewModelScope.launch { + startRealtimeChatUseCase(secret) + } + } + + + fun stopChatSession() { + viewModelScope.launch { + disconnectRealtimeUseCase() + } + } + + private var audioRecord: AudioRecord? = null + private var isRecording = false + + fun startRecordingAndSendAudio() { + audioRecord = AudioRecord( + MediaRecorder.AudioSource.MIC, + SAMPLE_RATE, + CHANNEL_CONFIG, + AUDIO_FORMAT, + BUFFER_SIZE + ) + + isRecording = true + audioRecord?.startRecording() + + viewModelScope.launch(Dispatchers.IO) { + val buffer = ByteArray(BUFFER_SIZE) + + while (isRecording) { + val readBytes = audioRecord?.read(buffer, 0, buffer.size) ?: 0 + if (readBytes > 0) { + val pcmData = buffer.copyOf(readBytes) + startRealtimeChatUseCase.sendPcm(pcmData) + } + } + + // 커밋 + startRealtimeChatUseCase.commitAudio() + } + } + + fun stopRecording() { + isRecording = false + audioRecord?.stop() + audioRecord?.release() + audioRecord = null + } + +companion object{ + private const val SAMPLE_RATE = 16000 // 16kHz + private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO // 모노 채널 + private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT // 16bit signed PCM + private val BUFFER_SIZE = AudioRecord.getMinBufferSize( + SAMPLE_RATE, + CHANNEL_CONFIG, + AUDIO_FORMAT + ).coerceAtLeast(SAMPLE_RATE * 2) // 1초 분량 or 최소값 이상 + +} +} + + + +class AiConversationState + diff --git a/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversationend/AiConversationEndScreen.kt b/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversationend/AiConversationEndScreen.kt new file mode 100644 index 00000000..73246ed5 --- /dev/null +++ b/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversationend/AiConversationEndScreen.kt @@ -0,0 +1,20 @@ +package com.saegil.ai_conversation.aiconversationend + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview + +@Composable +fun AiConversationEndScreen( + +) { + // TODO UI Rendering +} + +@Composable +@Preview(name = "AiConversationEnd") +private fun AiConversationEndScreenPreview() { + AiConversationEndScreen( + + ) +} + diff --git a/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversationend/AiConversationEndViewModel.kt b/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversationend/AiConversationEndViewModel.kt new file mode 100644 index 00000000..98d2a348 --- /dev/null +++ b/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversationend/AiConversationEndViewModel.kt @@ -0,0 +1,24 @@ +package com.saegil.ai_conversation.aiconversationend + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +@HiltViewModel +class AiConversationEndViewModel @Inject constructor( + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val _stateFlow: MutableStateFlow = + MutableStateFlow(AiConversationEndState()) + + val stateFlow: StateFlow = _stateFlow.asStateFlow() + + +} + +class AiConversationEndState \ No newline at end of file diff --git a/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversationlist/AiConversationListScreen.kt b/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversationlist/AiConversationListScreen.kt new file mode 100644 index 00000000..283598ed --- /dev/null +++ b/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversationlist/AiConversationListScreen.kt @@ -0,0 +1,80 @@ +package com.saegil.ai_conversation.aiconversationlist + +import android.util.Log +import androidx.compose.foundation.clickable + + +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.saegil.ai_conversation.aiconversationlist.components.CharacterCard +import com.saegil.ai_conversation.SaegilCharacter +import com.saegil.designsystem.component.SaegilTitleText +import com.saegil.designsystem.theme.SaegilAndroidTheme + +@Composable +fun AiConversationListScreen( + modifier: Modifier = Modifier, + onCharacterClick: (String) -> Unit = { character -> }, + viewModel: AiConversationListViewModel = hiltViewModel(), +) { + + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Column( + Modifier + .padding() + .verticalScroll(rememberScrollState()) + ) { + SaegilTitleText( + "AI 전화 회화", + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + Spacer(modifier = Modifier.height(30.dp)) + + CharacterCard( + modifier = Modifier, + character = SaegilCharacter.SAEROM, + onClick = { + onCharacterClick(SaegilCharacter.SAEROM.name) + }) + + Spacer(modifier = Modifier.height(30.dp)) + + CharacterCard(modifier = Modifier, + character = SaegilCharacter.GILDONG, + onClick = { + onCharacterClick(SaegilCharacter.GILDONG.name) + } + ) + } + } +} + +@Composable +@Preview(name = "Learning") +private fun LearningScreenPreview() { + SaegilAndroidTheme { + Surface { + AiConversationListScreen() + } + } +} + diff --git a/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversationlist/AiConversationListViewModel.kt b/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversationlist/AiConversationListViewModel.kt new file mode 100644 index 00000000..d5ba4f36 --- /dev/null +++ b/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversationlist/AiConversationListViewModel.kt @@ -0,0 +1,30 @@ +package com.saegil.ai_conversation.aiconversationlist + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.saegil.domain.usecase.GetRealTimeTokenUsecase +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@HiltViewModel +class AiConversationListViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + +) : ViewModel() { + + private val _stateFlow: MutableStateFlow = + MutableStateFlow(AiConversationListState()) + + val stateFlow: StateFlow = _stateFlow.asStateFlow() + + + +} + +class AiConversationListState \ No newline at end of file diff --git a/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversationlist/components/CallButton.kt b/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversationlist/components/CallButton.kt new file mode 100644 index 00000000..13a09e5c --- /dev/null +++ b/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversationlist/components/CallButton.kt @@ -0,0 +1,54 @@ +package com.saegil.ai_conversation.aiconversationlist.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.saegil.designsystem.theme.h2 + +@Composable +fun CallButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + + Button( + onClick = onClick, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF31C015), // #31C015 + contentColor = Color.White + ), + shape = RoundedCornerShape( + topStart = 8.dp, + topEnd = 8.dp, + bottomStart = 8.dp, + bottomEnd = 8.dp + ), + modifier = modifier + .fillMaxWidth() + .height(52.dp), + ) { + Text( + text = "전화 걸기", + color = MaterialTheme.colorScheme.background, + style = MaterialTheme.typography.h2, + ) + } + +} + + +@Preview +@Composable +fun CallButtonPreview() { + CallButton(onClick = {}) +} \ No newline at end of file diff --git a/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversationlist/components/CharacterCard.kt b/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversationlist/components/CharacterCard.kt new file mode 100644 index 00000000..a199a6b7 --- /dev/null +++ b/presentation/ai_conversation/src/main/java/com/saegil/ai_conversation/aiconversationlist/components/CharacterCard.kt @@ -0,0 +1,100 @@ +package com.saegil.ai_conversation.aiconversationlist.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.saegil.ai_conversation.SaegilCharacter +import com.saegil.designsystem.theme.SaegilAndroidTheme +import com.saegil.designsystem.theme.body2 +import com.saegil.designsystem.theme.h2 + +@Composable +fun CharacterCard( + modifier: Modifier = Modifier, + character: SaegilCharacter, + onClick: () -> Unit = {} +) { + + Box( + modifier = modifier + .padding(horizontal = 24.dp) + .background(MaterialTheme.colorScheme.background) + .border( + 1.dp, + color = MaterialTheme.colorScheme.scrim, + shape = RoundedCornerShape(4.dp) // ← 코너를 4dp만큼 둥글게 + ) + ) { + Column(horizontalAlignment = Alignment.Start) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Image( + painterResource(character.img), contentDescription = "새봄 프로필 사진", + modifier = Modifier.padding( + start = 24.dp, + top = 24.dp, + bottom = 18.dp, + end = 30.dp + ) + ) + + Spacer(modifier = Modifier.height(30.dp)) + + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Center, + ) { + Text( + character.nickname, + style = MaterialTheme.typography.h2, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(12.dp)) + Text(character.gender, style = MaterialTheme.typography.body2) + Spacer(modifier = Modifier.height(4.dp)) + Text(character.personality, style = MaterialTheme.typography.body2) + Spacer(modifier = Modifier.height(4.dp)) + Text(character.description, style = MaterialTheme.typography.body2) + } + } + + CallButton(onClick = { + onClick() + }, modifier = Modifier.padding(20.dp)) + } + } +} + + +@Preview +@Composable +fun CharacterCardPreview() { + SaegilAndroidTheme { + Column { + CharacterCard(modifier = Modifier, SaegilCharacter.SAEROM) + + CharacterCard(modifier = Modifier, SaegilCharacter.GILDONG) + } + } +} \ No newline at end of file diff --git a/presentation/ai_conversation/src/main/res/drawable/ic_launcher_background.xml b/presentation/ai_conversation/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/presentation/ai_conversation/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/presentation/ai_conversation/src/main/res/drawable/ic_launcher_foreground.xml b/presentation/ai_conversation/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..2b068d11 --- /dev/null +++ b/presentation/ai_conversation/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/presentation/ai_conversation/src/main/res/drawable/img_btn_end_call.png b/presentation/ai_conversation/src/main/res/drawable/img_btn_end_call.png new file mode 100644 index 00000000..9d950815 Binary files /dev/null and b/presentation/ai_conversation/src/main/res/drawable/img_btn_end_call.png differ diff --git a/presentation/ai_conversation/src/main/res/drawable/img_gildong.png b/presentation/ai_conversation/src/main/res/drawable/img_gildong.png new file mode 100644 index 00000000..3e1c8403 Binary files /dev/null and b/presentation/ai_conversation/src/main/res/drawable/img_gildong.png differ diff --git a/presentation/ai_conversation/src/main/res/drawable/img_saerom.png b/presentation/ai_conversation/src/main/res/drawable/img_saerom.png new file mode 100644 index 00000000..329d7956 Binary files /dev/null and b/presentation/ai_conversation/src/main/res/drawable/img_saerom.png differ diff --git a/presentation/ai_conversation/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/presentation/ai_conversation/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/presentation/ai_conversation/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/presentation/ai_conversation/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/presentation/ai_conversation/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/presentation/ai_conversation/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/presentation/ai_conversation/src/main/res/mipmap-hdpi/ic_launcher.webp b/presentation/ai_conversation/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..c209e78e Binary files /dev/null and b/presentation/ai_conversation/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/presentation/ai_conversation/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/presentation/ai_conversation/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..b2dfe3d1 Binary files /dev/null and b/presentation/ai_conversation/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/presentation/ai_conversation/src/main/res/mipmap-mdpi/ic_launcher.webp b/presentation/ai_conversation/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..4f0f1d64 Binary files /dev/null and b/presentation/ai_conversation/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/presentation/ai_conversation/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/presentation/ai_conversation/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..62b611da Binary files /dev/null and b/presentation/ai_conversation/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/presentation/ai_conversation/src/main/res/mipmap-xhdpi/ic_launcher.webp b/presentation/ai_conversation/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..948a3070 Binary files /dev/null and b/presentation/ai_conversation/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/presentation/ai_conversation/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/presentation/ai_conversation/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..1b9a6956 Binary files /dev/null and b/presentation/ai_conversation/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/presentation/ai_conversation/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/presentation/ai_conversation/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..28d4b77f Binary files /dev/null and b/presentation/ai_conversation/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/presentation/ai_conversation/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/presentation/ai_conversation/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..9287f508 Binary files /dev/null and b/presentation/ai_conversation/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/presentation/ai_conversation/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/presentation/ai_conversation/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..aa7d6427 Binary files /dev/null and b/presentation/ai_conversation/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/presentation/ai_conversation/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/presentation/ai_conversation/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..9126ae37 Binary files /dev/null and b/presentation/ai_conversation/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/presentation/ai_conversation/src/main/res/values-night/themes.xml b/presentation/ai_conversation/src/main/res/values-night/themes.xml new file mode 100644 index 00000000..639b2316 --- /dev/null +++ b/presentation/ai_conversation/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/presentation/ai_conversation/src/main/res/values/colors.xml b/presentation/ai_conversation/src/main/res/values/colors.xml new file mode 100644 index 00000000..f8c6127d --- /dev/null +++ b/presentation/ai_conversation/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/presentation/ai_conversation/src/main/res/values/strings.xml b/presentation/ai_conversation/src/main/res/values/strings.xml new file mode 100644 index 00000000..b70f8d9b --- /dev/null +++ b/presentation/ai_conversation/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Ai Conversation + \ No newline at end of file diff --git a/presentation/ai_conversation/src/main/res/values/themes.xml b/presentation/ai_conversation/src/main/res/values/themes.xml new file mode 100644 index 00000000..8bb42e60 --- /dev/null +++ b/presentation/ai_conversation/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/presentation/ai_conversation/src/test/java/com/saegil/ai_conversation/ExampleUnitTest.kt b/presentation/ai_conversation/src/test/java/com/saegil/ai_conversation/ExampleUnitTest.kt new file mode 100644 index 00000000..b31084f5 --- /dev/null +++ b/presentation/ai_conversation/src/test/java/com/saegil/ai_conversation/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.saegil.ai_conversation + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/presentation/learning/build.gradle.kts b/presentation/learning/build.gradle.kts index b4f5be03..d307c76d 100644 --- a/presentation/learning/build.gradle.kts +++ b/presentation/learning/build.gradle.kts @@ -71,4 +71,12 @@ dependencies { implementation("com.squareup.okhttp3:okhttp:4.11.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + + + implementation("io.ktor:ktor-client-core:2.3.7") + implementation("io.ktor:ktor-client-cio:2.3.7") // CIO 엔진 + implementation("io.ktor:ktor-client-websockets:2.3.7") + implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7") + + } \ No newline at end of file diff --git a/presentation/learning/src/main/java/com/saegil/learning/learning/LearningScreen.kt b/presentation/learning/src/main/java/com/saegil/learning/learning/LearningScreen.kt index 5bdb77e7..b3aa18fa 100644 --- a/presentation/learning/src/main/java/com/saegil/learning/learning/LearningScreen.kt +++ b/presentation/learning/src/main/java/com/saegil/learning/learning/LearningScreen.kt @@ -51,14 +51,22 @@ import com.saegil.designsystem.theme.h2 import com.saegil.designsystem.theme.h3 import com.saegil.learning.learning.components.CharacterEmotion import kotlinx.coroutines.delay - +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.websocket.* +import io.ktor.client.request.header +import io.ktor.http.HttpMethod +import io.ktor.websocket.* +import kotlinx.coroutines.* +import kotlinx.serialization.* +import kotlinx.serialization.json.* @Composable fun LearningScreen( modifier: Modifier = Modifier, navigateToLearningList: () -> Unit = {}, scenarioId: Long, scenarioName: String = "", - viewModel: LearningViewModel = hiltViewModel() + viewModel: LearningViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsState() val context = LocalContext.current diff --git a/presentation/news/src/main/java/com/saegil/news/news/NewsScreen.kt b/presentation/news/src/main/java/com/saegil/news/news/NewsScreen.kt index fe089f40..9e826b2b 100644 --- a/presentation/news/src/main/java/com/saegil/news/news/NewsScreen.kt +++ b/presentation/news/src/main/java/com/saegil/news/news/NewsScreen.kt @@ -3,6 +3,7 @@ package com.saegil.news.news import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues @@ -17,7 +18,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Checkbox @@ -103,10 +106,15 @@ internal fun NewsScreen( private fun LoadingState( modifier: Modifier = Modifier, ) { - SaegilLoadingWheel( + Box( modifier = modifier - .wrapContentSize() - ) + .fillMaxSize(), // 화면 전체를 채움 + contentAlignment = Alignment.Center // 가운데 정렬 + ) { + SaegilLoadingWheel( + modifier = Modifier.wrapContentSize() + ) + } } @Composable @@ -138,7 +146,8 @@ private fun NoTopicsState( Column( modifier = modifier .fillMaxSize() - .padding(horizontal = 35.dp), + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(modifier = Modifier.height(24.dp)) diff --git a/presentation/news/src/main/java/com/saegil/news/newsquiz/NewsQuizScreen.kt b/presentation/news/src/main/java/com/saegil/news/newsquiz/NewsQuizScreen.kt index d6e532c4..0391d62d 100644 --- a/presentation/news/src/main/java/com/saegil/news/newsquiz/NewsQuizScreen.kt +++ b/presentation/news/src/main/java/com/saegil/news/newsquiz/NewsQuizScreen.kt @@ -12,7 +12,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme @@ -84,10 +86,15 @@ internal fun NewsQuizScreen( private fun LoadingState( modifier: Modifier = Modifier, ) { - SaegilLoadingWheel( + Box( modifier = modifier - .wrapContentSize() - ) + .fillMaxSize(), // 화면 전체를 채움 + contentAlignment = Alignment.Center // 가운데 정렬 + ) { + SaegilLoadingWheel( + modifier = Modifier.wrapContentSize() + ) + } } @Composable @@ -100,7 +107,8 @@ internal fun QuizContent( Column( modifier = modifier - .fillMaxSize(), + .verticalScroll(rememberScrollState()) + .fillMaxSize(), verticalArrangement = Arrangement.spacedBy(16.dp) ) { Column( diff --git a/presentation/onboarding/src/main/java/com/saegil/onboarding/component/OnboardingPage.kt b/presentation/onboarding/src/main/java/com/saegil/onboarding/component/OnboardingPage.kt index 040b9a87..f0f793a2 100644 --- a/presentation/onboarding/src/main/java/com/saegil/onboarding/component/OnboardingPage.kt +++ b/presentation/onboarding/src/main/java/com/saegil/onboarding/component/OnboardingPage.kt @@ -6,14 +6,14 @@ sealed class OnboardingPage( val showButton: Boolean = false ) { data object OnboardingFirst : - OnboardingPage("언제 어디서나 대화 연습", "AI 시뮬레이션 대화를 통해 일상에서 자주\n일어나는 상황 속 대화를 연습할 수 있어요.") + OnboardingPage("언제 어디서나 대화 연습", "AI 전화 회화를 통해 일상에서 자주\n일어나는 상황 속 대화를 연습할 수 있어요.") data object OnboardingSecond : - OnboardingPage("공지사항을 한 곳에서 확인", "여기저기 흩어진 있던 북한이탈주민 대상\n공지사항들을 편하게 확인할 수 있어요.") + OnboardingPage("공지사항을 놓치지 않게", "푸시알림을 통해 정착에 도움이 되는\n공지사항을 제 때 확인할 수 있어요.") data object OnboardingThird : OnboardingPage( - "한 눈에 보는 근처 복지시설", - "현재 있는 장소 근처에 있는 복지시설의\n위치와 기관 정보를 쉽게 찾아볼 수 있어요.", + "관심사에 맞는 뉴스로 학습", + "관심사에 따라 하루 5개의 뉴스, OX 퀴즈로\n독해력과 사회 이해도를 향상시킬 수 있어요.", showButton = true ) diff --git a/settings.gradle.kts b/settings.gradle.kts index ceb1eda5..f1d058e1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,4 +37,5 @@ include(":presentation:splash") include(":core:ui") include(":presentation:log") include(":core:common") +include(":presentation:ai_conversation") include(":presentation:news")