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
Expand Up @@ -33,5 +33,17 @@ object PromptConstants {
0

분석할 문장:
"""

const val CREATE_IMAGE_BASIC_PROMPT = """
당신은 사용자의 사진첩 속 소중한 찰나를 기록하는 문학 작가입니다.
주어진 단어들을 활용해 그날의 공기와 기분이 느껴지는 짧은 감상평을 남겨주세요.

[지침]
1. '단어:' 뒤에 오는 정보를 자연스럽게 문장으로 엮으세요.
2. 너무 길지 않게, 핵심만 담백하게 전달하세요.
3. "~입니다" 또는 "~하네요"와 같은 정중한 어조를 유지하세요.

데이터:
"""
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@ import kotlinx.coroutines.flow.Flow

interface ImageAnalysisRepository {
fun getAnalysisStatus(context: Context): Flow<Boolean>

suspend fun runFullAnalysis()
suspend fun getImageDescription(uriString: String): Result<String?>
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,31 @@ import android.content.Context
import android.net.Uri
import android.util.Base64.decode
import android.util.Log
import androidx.core.net.toUri
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.example.metasearch.core.common.constants.PromptConstants
import com.example.metasearch.core.common.extensions.toFile
import com.example.metasearch.core.common.utils.runSuspendCatching
import com.example.metasearch.core.data.api.repository.DatabaseNameRepository
import com.example.metasearch.core.data.api.repository.GalleryRepository
import com.example.metasearch.core.data.api.repository.ImageAnalysisRepository
import com.example.metasearch.core.data.api.repository.PersonRepository
import com.example.metasearch.core.datastore.api.datasource.PersonIndexDataSource
import com.example.metasearch.core.network.request.ChangeNameRequest
import com.example.metasearch.core.network.request.DeleteImageRequest
import com.example.metasearch.core.network.request.OpenAIMessage
import com.example.metasearch.core.network.request.OpenAIRequest
import com.example.metasearch.core.network.service.AIService
import com.example.metasearch.core.network.service.OpenAIService
import com.example.metasearch.core.network.service.WebService
import com.example.metasearch.core.room.api.dao.AnalyzedImageDao
import com.example.metasearch.core.room.api.entity.AnalyzedImageEntity
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.supervisorScope
Expand All @@ -41,6 +48,7 @@ class ImageAnalysisRepositoryImpl @Inject constructor(
private val personRepository: PersonRepository,
private val aiService: AIService,
private val webService: WebService,
private val openAIService: OpenAIService,
@ApplicationContext private val context: Context,
) : ImageAnalysisRepository {
private val tag = "ImageAnalysisRepo"
Expand Down Expand Up @@ -92,6 +100,30 @@ class ImageAnalysisRepositoryImpl @Inject constructor(
syncMismatchedNames(dbName)
}

override suspend fun getImageDescription(uriString: String): Result<String> = runSuspendCatching {
coroutineScope {
val uri = uriString.toUri()
val photoNameDeferred = async { galleryRepository.getFileName(uri) }
val dbNameDeferred = async { databaseNameRepository.getPersistentDeviceDatabaseName() }

val photoName = photoNameDeferred.await()
?: error("파일 이름을 찾을 수 없음.")
val dbName = dbNameDeferred.await()

val tripleDataResponse = webService.fetchTripleData(dbName, photoName)
val fullPrompt = PromptConstants.CREATE_IMAGE_BASIC_PROMPT + tripleDataResponse.triple
val imageDescriptionResponse = openAIService.createChatCompletion(
OpenAIRequest(
model = "gpt-3.5-turbo",
messages = listOf(OpenAIMessage(role = "user", content = fullPrompt)),
),
)

imageDescriptionResponse.choices.firstOrNull()?.message?.content
?: error("AI 응답 내용이 비어 있음.")
}
}

private suspend fun deleteMissingImages(alreadyPaths: List<String>, currentPaths: List<String>, dbName: String) {
val deletePaths = alreadyPaths.filter { it !in currentPaths }
Log.d(tag, "삭제 대상 개수: ${deletePaths.size}개")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public interface WebService {
@Body request: PersonFrequencyRequest,
): PersonFrequencyResponse

@GET("/api/photoTripleData/{dbName}/{photoName}")
@GET("api/photoTripleData/{dbName}/{photoName}")
suspend fun fetchTripleData(
@Path("dbName") dbName: String,
@Path("photoName") photoName: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import com.example.metasearch.core.common.utils.handleException
import com.example.metasearch.core.data.api.repository.ImageAnalysisRepository
import com.example.metasearch.feature.screens.FocusingSearchScreen
import com.example.metasearch.feature.screens.GraphDetailScreen
import com.example.metasearch.feature.screens.PhotoDetailScreen
Expand All @@ -15,10 +18,12 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.components.ActivityRetainedComponent
import kotlinx.coroutines.launch

class PhotoDetailPresenter @AssistedInject constructor(
@Assisted private val navigator: Navigator,
@Assisted private val screen: PhotoDetailScreen,
private val imageAnalysisRepository: ImageAnalysisRepository,
) : Presenter<PhotoDetailUiState> {

@CircuitInject(PhotoDetailScreen::class, ActivityRetainedComponent::class)
Expand All @@ -32,12 +37,36 @@ class PhotoDetailPresenter @AssistedInject constructor(

@Composable
override fun present(): PhotoDetailUiState {
val scope = rememberCoroutineScope()
var isLoading by remember { mutableStateOf(false) }
var toastMessage by remember { mutableStateOf<String?>(null) }
val imageUriString by remember { mutableStateOf(screen.imageUriString) }
var imageDescription by remember { mutableStateOf<String?>(null) }

fun handleEvent(event: PhotoDetailUiEvent) {
when (event) {
is PhotoDetailUiEvent.OnCreateImageDescriptionButtonClick -> TODO()
is PhotoDetailUiEvent.OnCreateImageDescriptionButtonClick -> {
isLoading = true

scope.launch {
imageAnalysisRepository.getImageDescription(event.imageUriString)
.onSuccess { description ->
if (description != null) {
imageDescription = description
}
}
.onFailure { exception ->
handleException(
exception = exception,
onError = { message ->
toastMessage = message
},
)
}

isLoading = false
}
}

is PhotoDetailUiEvent.OnGraphButtonClick -> navigator.goTo(
GraphDetailScreen(
Expand All @@ -54,12 +83,18 @@ class PhotoDetailPresenter @AssistedInject constructor(
is PhotoDetailUiEvent.OnShareImageButtonClick -> TODO()

PhotoDetailUiEvent.OnBackClick -> navigator.pop()

PhotoDetailUiEvent.HideToast -> toastMessage = null

is PhotoDetailUiEvent.ShowToast -> toastMessage = event.message
}
}

return PhotoDetailUiState(
isLoading = isLoading,
toastMessage = toastMessage,
imageUriString = imageUriString,
imageDescription = imageDescription,
eventSink = ::handleEvent,
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.example.metasearch.feature.detail.photo

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.delay

@Composable
fun PhotoDetailToastEffect(
toastMessage: String? = null,
eventSink: (PhotoDetailUiEvent) -> Unit,
) {
LaunchedEffect(toastMessage) {
if (toastMessage != null) {
delay(1500L)

eventSink(PhotoDetailUiEvent.HideToast)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,63 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
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.Modifier
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
import com.example.metasearch.core.designsystem.annotation.DevicePreview
import com.example.metasearch.core.designsystem.component.MetaSearchToast
import com.example.metasearch.core.designsystem.theme.LightPink
import com.example.metasearch.core.designsystem.theme.MetaSearchTheme
import com.example.metasearch.core.ui.MetaSearchScaffold
import com.example.metasearch.core.ui.component.MetaSearchLoadingIndicator
import com.example.metasearch.feature.detail.photo.component.ImageDescriptionBottomSheetContent
import com.example.metasearch.feature.detail.photo.component.PhotoDetailBottomBar
import com.example.metasearch.feature.detail.photo.component.PhotoDetailBottomBarItem
import com.example.metasearch.feature.detail.photo.component.PhotoDetailHeader
import com.example.metasearch.feature.screens.PhotoDetailScreen
import com.slack.circuit.codegen.annotations.CircuitInject
import dagger.hilt.android.components.ActivityRetainedComponent

@OptIn(ExperimentalMaterial3Api::class)
@CircuitInject(PhotoDetailScreen::class, ActivityRetainedComponent::class)
@Composable
fun PhotoDetailUi(
modifier: Modifier = Modifier,
state: PhotoDetailUiState,
) {
val sheetState = rememberModalBottomSheetState()
var isSheetOpen by remember { mutableStateOf(false) }

LaunchedEffect(state.imageDescription) {
if (state.imageDescription != null) {
isSheetOpen = true
}
}
Comment on lines +45 to +49
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Sheet doesn't close when imageDescription becomes null.

The LaunchedEffect only opens the sheet when imageDescription is non-null, but never closes it. If imageDescription is reset to null (e.g., state reset, error recovery), the sheet remains open with stale or empty content.

Suggested fix
     LaunchedEffect(state.imageDescription) {
-        if (state.imageDescription != null) {
-            isSheetOpen = true
-        }
+        isSheetOpen = state.imageDescription != null
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
LaunchedEffect(state.imageDescription) {
if (state.imageDescription != null) {
isSheetOpen = true
}
}
LaunchedEffect(state.imageDescription) {
isSheetOpen = state.imageDescription != null
}
🤖 Prompt for AI Agents
In
@feature/detail/src/main/java/com/example/metasearch/feature/detail/photo/PhotoDetailUi.kt
around lines 45 - 49, LaunchedEffect currently only opens the sheet when
state.imageDescription is non-null but never closes it; update the
LaunchedEffect watching state.imageDescription to set isSheetOpen =
(state.imageDescription != null) so the sheet closes when imageDescription
becomes null (i.e., replace the conditional block with a single assignment
inside LaunchedEffect that sets isSheetOpen based on state.imageDescription).


PhotoDetailToastEffect(
toastMessage = state.toastMessage,
eventSink = state.eventSink,
)

MetaSearchScaffold(
modifier = modifier,
bottomBar = {
PhotoDetailBottomBar(
onTabClick = { tab ->
when (tab) {
PhotoDetailBottomBarItem.OPEN_AI -> TODO()
PhotoDetailBottomBarItem.OPEN_AI -> {
state.eventSink(PhotoDetailUiEvent.OnCreateImageDescriptionButtonClick(state.imageUriString))
}
PhotoDetailBottomBarItem.GRAPH -> {
state.eventSink(PhotoDetailUiEvent.OnGraphButtonClick)
}
Expand All @@ -48,6 +78,26 @@ fun PhotoDetailUi(
state = state,
innerPadding = innerPadding,
)

if (state.isLoading) {
MetaSearchLoadingIndicator()
}

if (isSheetOpen) {
ModalBottomSheet(
onDismissRequest = { isSheetOpen = false },
sheetState = sheetState,
containerColor = LightPink,
dragHandle = { BottomSheetDefaults.DragHandle() },
) {
ImageDescriptionBottomSheetContent(state.imageDescription ?: "")
}
}

MetaSearchToast(
isVisible = state.toastMessage != null,
message = state.toastMessage,
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import com.slack.circuit.runtime.CircuitUiState

data class PhotoDetailUiState(
val isLoading: Boolean = false,
val toastMessage: String? = null,
val imageUriString: String,
val imageDescription: String? = null,
val eventSink: (PhotoDetailUiEvent) -> Unit,
) : CircuitUiState

Expand Down Expand Up @@ -40,4 +42,10 @@ sealed interface PhotoDetailUiEvent : CircuitUiEvent {
* 헤더의 뒤로가기 버튼 클릭
*/
data object OnBackClick : PhotoDetailUiEvent

data class ShowToast(
val message: String,
) : PhotoDetailUiEvent

data object HideToast : PhotoDetailUiEvent
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.example.metasearch.feature.detail.photo.component

import androidx.compose.foundation.layout.Column
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.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.example.metasearch.core.designsystem.theme.MetaSearchTheme
import com.example.metasearch.feature.detail.R

@Composable
internal fun ImageDescriptionBottomSheetContent(description: String) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = MetaSearchTheme.spacing.spacing5)
.padding(bottom = MetaSearchTheme.spacing.spacing10),
) {
Text(
text = stringResource(R.string.photo_detail_screen_openai_bottom_sheet_title),
)
Spacer(modifier = Modifier.height(MetaSearchTheme.spacing.spacing4))
Text(
text = description,
style = MetaSearchTheme.typography.bodyLarge,
)
}
}
1 change: 1 addition & 0 deletions feature/detail/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@
<string name="photo_detail_screen_bottom_item_graph">탐색</string>
<string name="photo_detail_screen_bottom_item_focusing">포커싱 검색</string>
<string name="photo_detail_screen_bottom_item_share">공유</string>
<string name="photo_detail_screen_openai_bottom_sheet_title">🐻‍❄️ AI 이미지 분석 결과</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ class GraphPresenter @AssistedInject constructor(
val maxImages = 10

LaunchedEffect(Unit) {
// webViewUrl = "https://www.google.com"
webViewUrl = graphRepository.getFullGraphWebViewUrl()
}

Expand Down