diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index f3ddee4..098f78c 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,5 +1,5 @@
-
+
\ No newline at end of file
diff --git a/core/ui/src/main/java/com/nexters/fooddiary/core/ui/theme/Theme.kt b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/theme/Theme.kt
index c6ca6b9..8ef36ea 100644
--- a/core/ui/src/main/java/com/nexters/fooddiary/core/ui/theme/Theme.kt
+++ b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/theme/Theme.kt
@@ -1,17 +1,32 @@
package com.nexters.fooddiary.core.ui.theme
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
+private val FoodDiaryColorScheme = lightColorScheme(
+ primary = PrimBase,
+ onPrimary = White,
+ secondary = ComBase,
+ onSecondary = White,
+ tertiary = PersBase,
+ onTertiary = White,
+ error = RnBase,
+ onError = White,
+ background = GrayBase,
+ onBackground = White,
+ surface = GrayBase,
+ onSurface = White,
+)
@Composable
fun FoodDiaryTheme(
content: @Composable () -> Unit
) {
MaterialTheme(
- colorScheme = colorScheme,
+ colorScheme = FoodDiaryColorScheme,
typography = Typography,
content = content
)
}
+
diff --git a/data/build.gradle.kts b/data/build.gradle.kts
index 1611bbf..8cbbce9 100644
--- a/data/build.gradle.kts
+++ b/data/build.gradle.kts
@@ -47,6 +47,7 @@ android {
dependencies {
// Modules
implementation(projects.core.common)
+ implementation(projects.core.classification)
implementation(projects.domain)
// Coroutines
diff --git a/data/src/main/java/com/nexters/fooddiary/data/di/MediaModule.kt b/data/src/main/java/com/nexters/fooddiary/data/di/MediaModule.kt
index c0b8963..ebf28c2 100644
--- a/data/src/main/java/com/nexters/fooddiary/data/di/MediaModule.kt
+++ b/data/src/main/java/com/nexters/fooddiary/data/di/MediaModule.kt
@@ -2,7 +2,9 @@ package com.nexters.fooddiary.data.di
import android.content.ContentResolver
import android.content.Context
+import com.nexters.fooddiary.data.repository.ClassificationRepositoryImpl
import com.nexters.fooddiary.data.repository.MediaRepositoryImpl
+import com.nexters.fooddiary.domain.repository.ClassificationRepository
import com.nexters.fooddiary.domain.repository.MediaRepository
import dagger.Binds
import dagger.Module
@@ -32,4 +34,10 @@ abstract class MediaRepositoryModule {
internal abstract fun bindMediaRepository(
mediaRepositoryImpl: MediaRepositoryImpl
): MediaRepository
+
+ @Binds
+ @Singleton
+ internal abstract fun bindClassificationRepository(
+ classificationRepositoryImpl: ClassificationRepositoryImpl
+ ): ClassificationRepository
}
diff --git a/data/src/main/java/com/nexters/fooddiary/data/repository/ClassificationRepositoryImpl.kt b/data/src/main/java/com/nexters/fooddiary/data/repository/ClassificationRepositoryImpl.kt
new file mode 100644
index 0000000..9be83c1
--- /dev/null
+++ b/data/src/main/java/com/nexters/fooddiary/data/repository/ClassificationRepositoryImpl.kt
@@ -0,0 +1,64 @@
+package com.nexters.fooddiary.data.repository
+
+import android.content.Context
+import android.net.Uri
+import com.nexters.fooddiary.core.classification.FoodClassificationResult
+import com.nexters.fooddiary.core.classification.FoodClassifier
+import com.nexters.fooddiary.core.classification.ImageUtils
+import com.nexters.fooddiary.domain.model.ClassificationResult
+import com.nexters.fooddiary.domain.repository.ClassificationRepository
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ClassificationRepositoryImpl @Inject constructor(
+ private val foodClassifier: FoodClassifier,
+ @ApplicationContext private val context: Context
+) : ClassificationRepository {
+
+ private val classificationCache = mutableMapOf()
+
+ override suspend fun classifyImage(uriString: String): ClassificationResult? {
+ return withContext(Dispatchers.IO) {
+ val cacheKey = normalizeCacheKey(uriString)
+ val cachedResult = classificationCache[cacheKey]
+
+ if (cachedResult != null) {
+ return@withContext cachedResult.toDomainModel()
+ }
+ val uri = Uri.parse(uriString)
+ val bitmap = ImageUtils.uriToBitmap(context, uri) ?: return@withContext null
+
+ try {
+ val result = foodClassifier.classifyAsync(bitmap)
+ classificationCache[cacheKey] = result
+ result.toDomainModel()
+ } catch (e: Exception) {
+ null
+ }
+ }
+ }
+
+ private fun normalizeCacheKey(uriString: String): String {
+ return try {
+ Uri.parse(uriString).toString()
+ } catch (e: Exception) {
+ uriString
+ }
+ }
+
+ fun clearCache() {
+ classificationCache.clear()
+ }
+
+ private fun FoodClassificationResult.toDomainModel(): ClassificationResult {
+ return ClassificationResult(
+ isFood = this.isFood,
+ foodConfidence = this.foodConfidence,
+ notFoodConfidence = this.notFoodConfidence
+ )
+ }
+}
diff --git a/domain/src/main/kotlin/com/nexters/fooddiary/domain/model/ClassificationResult.kt b/domain/src/main/kotlin/com/nexters/fooddiary/domain/model/ClassificationResult.kt
new file mode 100644
index 0000000..268898d
--- /dev/null
+++ b/domain/src/main/kotlin/com/nexters/fooddiary/domain/model/ClassificationResult.kt
@@ -0,0 +1,10 @@
+package com.nexters.fooddiary.domain.model
+
+data class ClassificationResult(
+ val isFood: Boolean,
+ val foodConfidence: Float,
+ val notFoodConfidence: Float
+) {
+ val maxConfidence: Float
+ get() = maxOf(foodConfidence, notFoodConfidence)
+}
diff --git a/domain/src/main/kotlin/com/nexters/fooddiary/domain/repository/ClassificationRepository.kt b/domain/src/main/kotlin/com/nexters/fooddiary/domain/repository/ClassificationRepository.kt
new file mode 100644
index 0000000..2691906
--- /dev/null
+++ b/domain/src/main/kotlin/com/nexters/fooddiary/domain/repository/ClassificationRepository.kt
@@ -0,0 +1,7 @@
+package com.nexters.fooddiary.domain.repository
+
+import com.nexters.fooddiary.domain.model.ClassificationResult
+
+interface ClassificationRepository {
+ suspend fun classifyImage(uriString: String): ClassificationResult?
+}
diff --git a/domain/src/main/kotlin/com/nexters/fooddiary/domain/repository/MediaRepository.kt b/domain/src/main/kotlin/com/nexters/fooddiary/domain/repository/MediaRepository.kt
index 598ea49..69b543e 100644
--- a/domain/src/main/kotlin/com/nexters/fooddiary/domain/repository/MediaRepository.kt
+++ b/domain/src/main/kotlin/com/nexters/fooddiary/domain/repository/MediaRepository.kt
@@ -4,29 +4,9 @@ import com.nexters.fooddiary.domain.model.MediaItem
import java.time.LocalDate
import java.time.YearMonth
-/**
- * 미디어 데이터 Repository 인터페이스
- */
interface MediaRepository {
- /**
- * 특정 월의 사진을 날짜별로 그룹화하여 반환
- */
suspend fun getPhotosByMonth(yearMonth: YearMonth): Map>
-
- /**
- * 특정 월의 날짜별 사진 개수를 반환
- */
suspend fun getPhotoCountByDate(yearMonth: YearMonth): Map
-
- /**
- * 전체 앨범의 모든 사진을 날짜별로 그룹화하여 반환
- * 성능 비교를 위한 전체 스캔 함수
- */
suspend fun getAllPhotos(): Map>
-
- /**
- * 전체 앨범의 날짜별 사진 개수를 반환
- * 성능 비교를 위한 전체 스캔 함수
- */
suspend fun getAllPhotoCountByDate(): Map
}
diff --git a/domain/src/main/kotlin/com/nexters/fooddiary/domain/usecase/ClassifyImageUseCase.kt b/domain/src/main/kotlin/com/nexters/fooddiary/domain/usecase/ClassifyImageUseCase.kt
new file mode 100644
index 0000000..9c5be7b
--- /dev/null
+++ b/domain/src/main/kotlin/com/nexters/fooddiary/domain/usecase/ClassifyImageUseCase.kt
@@ -0,0 +1,13 @@
+package com.nexters.fooddiary.domain.usecase
+
+import com.nexters.fooddiary.domain.model.ClassificationResult
+import com.nexters.fooddiary.domain.repository.ClassificationRepository
+import javax.inject.Inject
+
+class ClassifyImageUseCase @Inject constructor(
+ private val classificationRepository: ClassificationRepository
+) {
+ suspend operator fun invoke(uriString: String): ClassificationResult? {
+ return classificationRepository.classifyImage(uriString)
+ }
+}
diff --git a/domain/src/main/kotlin/com/nexters/fooddiary/domain/usecase/GetTodayFoodPhotosUseCase.kt b/domain/src/main/kotlin/com/nexters/fooddiary/domain/usecase/GetTodayFoodPhotosUseCase.kt
new file mode 100644
index 0000000..a703906
--- /dev/null
+++ b/domain/src/main/kotlin/com/nexters/fooddiary/domain/usecase/GetTodayFoodPhotosUseCase.kt
@@ -0,0 +1,45 @@
+package com.nexters.fooddiary.domain.usecase
+
+import com.nexters.fooddiary.domain.repository.ClassificationRepository
+import com.nexters.fooddiary.domain.repository.MediaRepository
+import java.time.LocalDate
+import java.time.YearMonth
+import javax.inject.Inject
+
+class GetTodayFoodPhotosUseCase @Inject constructor(
+ private val mediaRepository: MediaRepository,
+ private val classificationRepository: ClassificationRepository
+) {
+ suspend operator fun invoke(): List {
+ val today = LocalDate.now()
+ val currentMonth = YearMonth.now()
+
+ val photosByMonth = mediaRepository.getPhotosByMonth(currentMonth)
+ val todayPhotos = photosByMonth[today] ?: emptyList()
+
+ if (todayPhotos.isEmpty()) {
+ return emptyList()
+ }
+
+ val withResult = todayPhotos.map { mediaItem ->
+ val uriString = mediaItem.uri
+ val result = classificationRepository.classifyImage(uriString)
+ PhotoWithClassification(uriString, result)
+ }
+
+ val foodFirst = withResult
+ .filter { (_, result) -> result != null && result.isFood }
+ .sortedByDescending { (_, result) -> result?.foodConfidence ?: 0f }
+ .map { it.uriString }
+ val rest = withResult
+ .filter { (_, result) -> result == null || !result.isFood }
+ .map { it.uriString }
+
+ return foodFirst + rest
+ }
+
+ private data class PhotoWithClassification(
+ val uriString: String,
+ val result: com.nexters.fooddiary.domain.model.ClassificationResult?
+ )
+}
diff --git a/domain/src/test/kotlin/com/nexters/fooddiary/domain/usecase/ClassifyImageUseCaseTest.kt b/domain/src/test/kotlin/com/nexters/fooddiary/domain/usecase/ClassifyImageUseCaseTest.kt
new file mode 100644
index 0000000..44bcd1e
--- /dev/null
+++ b/domain/src/test/kotlin/com/nexters/fooddiary/domain/usecase/ClassifyImageUseCaseTest.kt
@@ -0,0 +1,56 @@
+package com.nexters.fooddiary.domain.usecase
+
+import com.nexters.fooddiary.domain.model.ClassificationResult
+import com.nexters.fooddiary.domain.repository.ClassificationRepository
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+
+class ClassifyImageUseCaseTest {
+
+ @Test
+ fun `같은 URI로 두 번 호출 시 캐시된 결과가 반환된다`() = runTest {
+ val fakeRepo = FakeClassificationRepository()
+ val useCase = ClassifyImageUseCase(fakeRepo)
+ val uri = "content://media/external/images/media/123"
+
+ val result1 = useCase(uri)
+ val result2 = useCase(uri)
+
+ assertNotNull(result1)
+ assertNotNull(result2)
+ assertEquals(result1, result2)
+ assertEquals(1, fakeRepo.classifyCallCount)
+ }
+
+ @Test
+ fun `다른 URI로 호출 시 각각 분류가 수행된다`() = runTest {
+ val fakeRepo = FakeClassificationRepository()
+ val useCase = ClassifyImageUseCase(fakeRepo)
+
+ useCase("content://media/external/images/media/1")
+ useCase("content://media/external/images/media/2")
+
+ assertEquals(2, fakeRepo.classifyCallCount)
+ }
+
+ private class FakeClassificationRepository : ClassificationRepository {
+ var classifyCallCount = 0
+ private val cache = mutableMapOf()
+
+ override suspend fun classifyImage(uriString: String): ClassificationResult? {
+ cache[uriString]?.let {
+ return it
+ }
+ classifyCallCount++
+ val result = ClassificationResult(
+ isFood = true,
+ foodConfidence = 0.9f,
+ notFoodConfidence = 0.1f
+ )
+ cache[uriString] = result
+ return result
+ }
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 4a05eed..32070ac 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -11,7 +11,7 @@ espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.8.7"
lifecycleViewmodelCompose = "2.8.7"
activityCompose = "1.9.3"
-composeBom = "2025.12.01"
+composeBom = "2024.11.00"
hilt = "2.58"
hiltNavigationCompose = "1.3.0"
retrofit = "3.0.0"
@@ -37,6 +37,9 @@ minSdk = "26"
compileSdk = "36"
targetSdk = "36"
calendar = "2.6.2"
+uiToolingPreviewAndroid = "1.10.1"
+coil = "2.7.0"
+material3Android = "1.4.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -129,6 +132,11 @@ androidx-datastore-preferences = { group = "androidx.datastore", name = "datasto
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" }
firebase-auth = { group = "com.google.firebase", name = "firebase-auth" }
+androidx-ui-tooling-preview-android = { group = "androidx.compose.ui", name = "ui-tooling-preview-android", version.ref = "uiToolingPreviewAndroid" }
+
+# Coil
+coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
+androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" }
[plugins]
google-gms-services = { id = "com.google.gms.google-services", version = "4.4.4" }
diff --git a/presentation/camera/src/main/java/com/nexters/fooddiary/presentation/camera/CameraScreen.kt b/presentation/camera/src/main/java/com/nexters/fooddiary/presentation/camera/CameraScreen.kt
deleted file mode 100644
index e69de29..0000000
diff --git a/presentation/image/build.gradle.kts b/presentation/image/build.gradle.kts
index 646c8ba..fd1821c 100644
--- a/presentation/image/build.gradle.kts
+++ b/presentation/image/build.gradle.kts
@@ -40,6 +40,7 @@ dependencies {
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.activity.compose)
+ debugImplementation(libs.androidx.compose.ui.tooling)
// Lifecycle
implementation(libs.androidx.lifecycle.runtime.ktx)
@@ -56,6 +57,9 @@ dependencies {
// ExifInterface for image rotation
implementation(libs.androidx.exifinterface)
+ // Coil for image loading
+ implementation(libs.coil.compose)
+
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
@@ -70,5 +74,9 @@ dependencies {
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.hilt.testing)
+
+ // module
+ implementation(projects.domain)
+ implementation(projects.core.common)
}
diff --git a/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/ImageClassificationScreen.kt b/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/ImageClassificationScreen.kt
index d94e38e..57111c4 100644
--- a/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/ImageClassificationScreen.kt
+++ b/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/ImageClassificationScreen.kt
@@ -2,13 +2,11 @@ package com.nexters.fooddiary.presentation.image
import android.content.Context
import android.graphics.Bitmap
-import android.net.Uri
import android.widget.Toast
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -16,6 +14,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
@@ -26,7 +26,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
+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.graphics.asImageBitmap
@@ -38,7 +40,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
-import com.nexters.fooddiary.core.classification.FoodClassificationResult
+import com.nexters.fooddiary.core.common.toPercentageString
+import com.nexters.fooddiary.domain.model.ClassificationResult
@Composable
internal fun ImageClassificationScreen(
@@ -47,61 +50,65 @@ internal fun ImageClassificationScreen(
) {
val context = LocalContext.current
val state by viewModel.collectAsState()
-
- val mimeType = remember { context.getString(R.string.image_mime_type) }
-
- val imagePickerLauncher = rememberLauncherForActivityResult(
- contract = ActivityResultContracts.GetContent()
- ) { uri: Uri? ->
- uri?.let { viewModel.loadImageFromUri(it) }
- }
+ var showImagePicker by remember { mutableStateOf(false) }
- LaunchedEffect(state.classificationResult, state.errorMessage) {
- state.classificationResult?.let { result ->
- when (result) {
- is ClassificationResult.Complete -> showClassificationResultToast(context, result.result)
- }
- }
-
+ LaunchedEffect(state.errorMessage) {
state.errorMessage?.let { message ->
showToast(context, message, Toast.LENGTH_SHORT)
}
}
- Box(
- modifier = Modifier
- .fillMaxSize()
- .background(MaterialTheme.colorScheme.background)
- ) {
- Column(
+ if (showImagePicker) {
+ ImagePickerScreen(
+ onImagesSelected = { uris ->
+ if (uris.isNotEmpty()) {
+ viewModel.loadImagesFromUris(uris)
+ }
+ showImagePicker = false
+ },
+ onClose = { showImagePicker = false }
+ )
+ } else {
+ Box(
modifier = Modifier
.fillMaxSize()
- .padding(24.dp),
- horizontalAlignment = Alignment.CenterHorizontally
+ .background(MaterialTheme.colorScheme.background)
) {
- CloseButton(
- onClick = onClose,
- modifier = Modifier.align(Alignment.Start)
- )
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ CloseButton(
+ onClick = onClose,
+ modifier = Modifier.align(Alignment.Start)
+ )
- state.selectedImage?.let { image ->
- key(image) {
- ImageDisplaySection(
- image = image,
- state = state,
- context = context
- )
+ if (state.selectedItems.isNotEmpty()) {
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ itemsIndexed(state.selectedItems) { index, item ->
+ key(item.bitmap.hashCode(), index) {
+ ImageDisplaySection(
+ item = item,
+ isClassifying = state.isLoading && item.classificationResult == null,
+ context = context
+ )
+ }
+ }
+ }
}
- Spacer(modifier = Modifier.height(16.dp))
- }
- AlbumSelectionButton(
- hasSelectedImage = state.hasSelectedImage,
- onClick = { imagePickerLauncher.launch(mimeType) }
- )
+ AlbumSelectionButton(
+ hasSelectedImage = state.hasSelectedImage,
+ onClick = { showImagePicker = true }
+ )
- if (!state.hasSelectedImage) {
- SelectionHint()
+ if (!state.hasSelectedImage) {
+ SelectionHint()
+ }
}
}
}
@@ -137,13 +144,12 @@ private fun SelectionHint() {
@Composable
private fun ImageDisplaySection(
- image: Bitmap,
- state: ImageClassificationState,
+ item: ClassifiedImageItem,
+ isClassifying: Boolean,
context: Context
) {
val displayHeight = integerResource(R.integer.image_display_height_dp)
- val imageBitmap = remember(image) { image.asImageBitmap() }
-
+ val imageBitmap = remember(item.bitmap) { item.bitmap.asImageBitmap() }
Box(
modifier = Modifier
.fillMaxWidth()
@@ -161,16 +167,12 @@ private fun ImageDisplaySection(
modifier = Modifier.fillMaxSize()
)
}
-
- Spacer(modifier = Modifier.height(16.dp))
-
+ Spacer(modifier = Modifier.height(8.dp))
when {
- state.isClassifying -> {
- ClassifyingText()
- }
- state.classificationResult is ClassificationResult.Complete -> {
+ isClassifying -> ClassifyingText()
+ item.classificationResult != null -> {
ClassificationResultText(
- result = (state.classificationResult as ClassificationResult.Complete).result,
+ result = item.classificationResult,
context = context
)
}
@@ -189,19 +191,18 @@ private fun ClassifyingText() {
@Composable
private fun ClassificationResultText(
- result: FoodClassificationResult,
+ result: ClassificationResult,
context: Context
) {
val foodMessage = remember { context.getString(R.string.image_classification_food) }
val notFoodMessage = remember { context.getString(R.string.image_classification_not_food) }
val message = remember(result, foodMessage, notFoodMessage) {
- result.toDisplayMessage(foodMessage, notFoodMessage)
+ formatClassificationMessage(result, foodMessage, notFoodMessage)
}
val textColor = when {
result.isFood -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.error
}
-
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
@@ -235,14 +236,19 @@ private fun AlbumSelectionButton(
}
}
-private fun showClassificationResultToast(
- context: Context,
- result: FoodClassificationResult
-) {
- val foodMessage = context.getString(R.string.image_classification_food)
- val notFoodMessage = context.getString(R.string.image_classification_not_food)
- val message = result.toDisplayMessage(foodMessage, notFoodMessage)
- showToast(context, message, Toast.LENGTH_LONG)
+private fun formatClassificationMessage(
+ result: ClassificationResult,
+ foodMessage: String,
+ notFoodMessage: String
+): String {
+ val confidence = when {
+ result.isFood -> result.foodConfidence
+ else -> result.notFoodConfidence
+ }.toPercentageString(1)
+ return when {
+ result.isFood -> foodMessage.format(confidence)
+ else -> notFoodMessage.format(confidence)
+ }
}
private fun showToast(context: Context, message: String, duration: Int) {
diff --git a/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/ImageClassificationState.kt b/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/ImageClassificationState.kt
index adec368..a45ef01 100644
--- a/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/ImageClassificationState.kt
+++ b/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/ImageClassificationState.kt
@@ -2,22 +2,21 @@ package com.nexters.fooddiary.presentation.image
import android.graphics.Bitmap
import com.airbnb.mvrx.MavericksState
-import com.nexters.fooddiary.core.classification.FoodClassificationResult
+import com.nexters.fooddiary.domain.model.ClassificationResult
+
+data class ClassifiedImageItem(
+ val bitmap: Bitmap,
+ val classificationResult: ClassificationResult? = null
+)
data class ImageClassificationState(
- val selectedImage: Bitmap? = null,
- val classificationResult: ClassificationResult? = null,
+ val selectedItems: List = emptyList(),
val isLoading: Boolean = false,
val errorMessage: String? = null
) : MavericksState {
val hasSelectedImage: Boolean
- get() = selectedImage != null
+ get() = selectedItems.isNotEmpty()
val isClassifying: Boolean
- get() = isLoading && selectedImage != null
-}
-
-sealed class ClassificationResult {
- data class Complete(val result: FoodClassificationResult) : ClassificationResult()
+ get() = isLoading && selectedItems.isNotEmpty()
}
-
diff --git a/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/ImageClassificationViewModel.kt b/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/ImageClassificationViewModel.kt
index 956fe8a..0d33c5f 100644
--- a/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/ImageClassificationViewModel.kt
+++ b/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/ImageClassificationViewModel.kt
@@ -1,156 +1,79 @@
package com.nexters.fooddiary.presentation.image
import android.content.Context
-import android.graphics.Bitmap
import android.net.Uri
import com.airbnb.mvrx.MavericksViewModel
import com.airbnb.mvrx.MavericksViewModelFactory
-import com.airbnb.mvrx.ViewModelContext
import com.airbnb.mvrx.hilt.AssistedViewModelFactory
import com.airbnb.mvrx.hilt.hiltMavericksViewModelFactory
-import com.nexters.fooddiary.core.classification.FoodClassifier
import com.nexters.fooddiary.core.classification.ImageUtils
+import com.nexters.fooddiary.domain.usecase.ClassifyImageUseCase
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
-import dagger.hilt.EntryPoint
-import dagger.hilt.InstallIn
-import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.qualifiers.ApplicationContext
-import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-import java.io.IOException
-import javax.inject.Inject
+import java.util.Collections.emptyList
class ImageClassificationViewModel @AssistedInject constructor(
@Assisted initialState: ImageClassificationState,
- @ApplicationContext private val context: Context
+ @ApplicationContext private val context: Context,
+ private val classifyImageUseCase: ClassifyImageUseCase
) : MavericksViewModel(initialState) {
- @Inject lateinit var classifier: FoodClassifier
- fun loadImageFromUri(uri: Uri) {
+ fun loadImagesFromUris(uris: List) {
+ if (uris.isEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
- updateLoadingState(isLoading = true)
-
- val bitmap = loadBitmapFromUri(uri)
-
- withContext(Dispatchers.Main) {
- when {
- bitmap != null -> handleImageLoaded(bitmap)
- else -> handleImageLoadError()
+ setState { copy(isLoading = true, errorMessage = null) }
+ val loaded = uris.mapNotNull { uri ->
+ ImageUtils.uriToBitmap(context, uri)?.let { bitmap -> uri to bitmap }
+ }
+ if (loaded.isEmpty()) {
+ setState {
+ copy(
+ selectedItems = emptyList(),
+ isLoading = false,
+ errorMessage = context.getString(R.string.image_load_failed)
+ )
}
+ return@launch
}
- }
- }
-
- private fun loadBitmapFromUri(uri: Uri): Bitmap? {
- return ImageUtils.uriToBitmap(context, uri)
- }
-
- private fun handleImageLoaded(bitmap: Bitmap) {
- updateStateWithImage(bitmap)
- startClassificationIfPossible(bitmap)
- }
-
- private fun updateStateWithImage(bitmap: Bitmap) {
- setState {
- copy(
- selectedImage = bitmap,
- isLoading = false,
- errorMessage = null,
- classificationResult = null
- )
- }
- }
-
- private fun startClassificationIfPossible(bitmap: Bitmap) {
- when {
- classifier != null -> classifyImage(bitmap, classifier!!)
- else -> handleClassifierNotAvailableError()
- }
- }
-
- private fun handleClassifierNotAvailableError() {
- setState {
- copy(
- errorMessage = context.getString(R.string.image_model_load_failed),
- isLoading = false
- )
- }
- }
-
- private fun handleImageLoadError() {
- setState {
- copy(
- errorMessage = context.getString(R.string.image_load_failed),
- isLoading = false
- )
- }
- }
-
- private fun classifyImage(bitmap: Bitmap, classifier: FoodClassifier) {
- viewModelScope.launch(Dispatchers.Default) {
- updateLoadingState(isLoading = true)
-
- try {
- val result = classifier.classifyAsync(bitmap)
- handleClassificationSuccess(result)
- } catch (e: Exception) {
- handleClassificationError(e)
+ val items = loaded.map { (_, bitmap) -> ClassifiedImageItem(bitmap, null) }
+ withContext(Dispatchers.Main) {
+ setState { copy(selectedItems = items, isLoading = true) }
+ }
+ loaded.forEachIndexed { index, (uri, _) ->
+ val result = runCatching {
+ classifyImageUseCase(uri.toString())
+ }.getOrNull()
+ val idx = index
+ withContext(Dispatchers.Main) {
+ setState {
+ copy(
+ selectedItems = this.selectedItems.mapIndexed { i, item ->
+ if (i == idx) item.copy(classificationResult = result)
+ else item
+ },
+ isLoading = idx < loaded.lastIndex
+ )
+ }
+ }
}
}
}
- private fun handleClassificationSuccess(result: com.nexters.fooddiary.core.classification.FoodClassificationResult) {
- setState {
- copy(
- classificationResult = ClassificationResult.Complete(result),
- isLoading = false
- )
- }
- }
-
- private fun handleClassificationError(exception: Exception) {
- val errorMessage = createClassificationErrorMessage(exception)
- setState {
- copy(
- errorMessage = errorMessage,
- isLoading = false
- )
- }
- }
-
- private fun createClassificationErrorMessage(exception: Exception): String {
- val fallbackMessage = context.getString(R.string.image_load_failed)
- val exceptionMessage = exception.message ?: fallbackMessage
- return context.getString(
- R.string.image_classification_error,
- exceptionMessage
- )
- }
-
- private fun updateLoadingState(isLoading: Boolean) {
- setState { copy(isLoading = isLoading) }
- }
-
fun clearImage() {
setState {
copy(
- selectedImage = null,
- classificationResult = null,
+ selectedItems = emptyList(),
errorMessage = null,
isLoading = false
)
}
}
- override fun onCleared() {
- super.onCleared()
- classifier?.close()
- }
-
@AssistedFactory
interface Factory : AssistedViewModelFactory {
override fun create(state: ImageClassificationState): ImageClassificationViewModel
diff --git a/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/ImagePickerScreen.kt b/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/ImagePickerScreen.kt
new file mode 100644
index 0000000..80e203a
--- /dev/null
+++ b/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/ImagePickerScreen.kt
@@ -0,0 +1,337 @@
+package com.nexters.fooddiary.presentation.image
+
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+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.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+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 com.airbnb.mvrx.compose.collectAsState
+import com.airbnb.mvrx.compose.mavericksViewModel
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import com.nexters.fooddiary.core.common.permission.PermissionUtil
+
+private const val PREVIEW_IMAGE_URL = "https://picsum.photos/200/200?random="
+
+private object ImagePickerDimens {
+ val screenPadding: Dp = 20.dp
+ val headerBottomPadding: Dp = 16.dp
+ val headerIconStartPadding: Dp = 30.dp
+ val gridMinCellSize: Dp = 104.dp
+ val gridPadding: Dp = 4.dp
+ val gridItemPadding: Dp = 4.dp
+ val gridItemCornerRadius: Dp = 8.dp
+ val checkIconPadding: Dp = 6.dp
+ val checkIconSize: Dp = 24.dp
+ val doneButtonTopPadding: Dp = 12.dp
+ val permissionButtonTopPadding: Dp = 16.dp
+}
+
+@Composable
+fun ImagePickerScreen(
+ modifier: Modifier = Modifier,
+ onImagesSelected: (List) -> Unit,
+ onClose: () -> Unit,
+ viewModel: ImagePickerViewModel = mavericksViewModel()
+) {
+ val state by viewModel.collectAsState()
+
+ val requiredPermission = PermissionUtil.getRequiredMediaPermission()
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted ->
+ if (isGranted) {
+ viewModel.onPermissionGranted()
+ }
+ }
+
+ LaunchedEffect(state.hasPermission) {
+ if (!state.hasPermission) {
+ permissionLauncher.launch(requiredPermission)
+ }
+ }
+
+ ImagePickerContent(
+ modifier = modifier,
+ imageUris = state.imageUris,
+ isLoading = state.isLoading,
+ hasPermission = state.hasPermission,
+ selectedUris = state.selectedUris,
+ onImageClick = { uri -> viewModel.toggleImageSelection(uri) },
+ onDone = {
+ onImagesSelected(state.selectedUris.toList())
+ },
+ onClose = onClose,
+ onRequestPermission = { permissionLauncher.launch(requiredPermission) }
+ )
+}
+
+
+@Composable
+fun ImagePickerContent(
+ modifier: Modifier = Modifier,
+ imageUris: List = emptyList(),
+ isLoading: Boolean = false,
+ hasPermission: Boolean = true,
+ selectedUris: Set = emptySet(),
+ onImageClick: (Uri) -> Unit = {},
+ onDone: () -> Unit = {},
+ onClose: () -> Unit = {},
+ onRequestPermission: () -> Unit = {}
+) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ .padding(ImagePickerDimens.screenPadding)
+ ) {
+ ImagePickerHeader(onClose = onClose)
+
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ ) {
+ ImagePickerContentArea(
+ imageUris = imageUris,
+ isLoading = isLoading,
+ hasPermission = hasPermission,
+ selectedUris = selectedUris,
+ onImageClick = onImageClick,
+ onRequestPermission = onRequestPermission
+ )
+ }
+
+ ImagePickerDoneButton(onClick = onDone)
+ }
+}
+
+@Composable
+private fun ImagePickerHeader(onClose: () -> Unit) {
+ val backDesc = stringResource(R.string.image_picker_back)
+ val iconDesc = stringResource(R.string.image_picker_icon)
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = ImagePickerDimens.headerBottomPadding),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Image(
+ imageVector = ImageVector.vectorResource(R.drawable.icon_back),
+ contentDescription = backDesc,
+ modifier = Modifier.clickable { onClose() }
+ )
+ Image(
+ imageVector = ImageVector.vectorResource(R.drawable.ic_drawer),
+ contentDescription = iconDesc,
+ modifier = Modifier.padding(start = ImagePickerDimens.headerIconStartPadding)
+ )
+ }
+}
+
+@Composable
+private fun ImagePickerContentArea(
+ imageUris: List,
+ isLoading: Boolean,
+ hasPermission: Boolean,
+ selectedUris: Set,
+ onImageClick: (Uri) -> Unit,
+ onRequestPermission: () -> Unit
+) {
+ when {
+ !hasPermission -> {
+ PermissionRequestView(onRequestPermission = onRequestPermission)
+ }
+ isLoading -> {
+ LoadingView()
+ }
+ imageUris.isEmpty() -> {
+ EmptyImageView()
+ }
+ else -> {
+ ImageGrid(
+ imageUris = imageUris,
+ selectedUris = selectedUris,
+ onImageClick = onImageClick
+ )
+ }
+ }
+}
+
+@Composable
+private fun PermissionRequestView(onRequestPermission: () -> Unit) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(stringResource(R.string.image_picker_permission_message))
+ Button(
+ onClick = onRequestPermission,
+ modifier = Modifier.padding(top = ImagePickerDimens.permissionButtonTopPadding)
+ ) {
+ Text(stringResource(R.string.image_picker_request_permission))
+ }
+ }
+ }
+}
+
+@Composable
+private fun LoadingView() {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(stringResource(R.string.image_picker_loading))
+ }
+}
+
+@Composable
+private fun EmptyImageView() {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(stringResource(R.string.image_picker_empty))
+ }
+}
+
+@Composable
+private fun ImageGrid(
+ imageUris: List,
+ selectedUris: Set,
+ onImageClick: (Uri) -> Unit
+) {
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(minSize = ImagePickerDimens.gridMinCellSize),
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(ImagePickerDimens.gridPadding),
+ ) {
+ items(imageUris) { uri ->
+ ImageGridItem(
+ uri = uri,
+ isSelected = selectedUris.contains(uri),
+ onClick = { onImageClick(uri) }
+ )
+ }
+ }
+}
+
+@Composable
+private fun ImageGridItem(
+ uri: Uri,
+ isSelected: Boolean,
+ onClick: () -> Unit
+) {
+ val galleryDesc = stringResource(R.string.image_picker_gallery_image)
+ val selectedDesc = stringResource(R.string.image_picker_selected)
+ val unselectedDesc = stringResource(R.string.image_picker_unselected)
+ val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent
+ Box(
+ modifier = Modifier
+ .aspectRatio(1f)
+ .padding(ImagePickerDimens.gridItemPadding)
+ ) {
+ AsyncImage(
+ model = uri,
+ contentDescription = galleryDesc,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .fillMaxSize()
+ .clip(RoundedCornerShape(ImagePickerDimens.gridItemCornerRadius))
+ .border(
+ width = 1.dp,
+ color = borderColor,
+ shape = RoundedCornerShape(ImagePickerDimens.gridItemCornerRadius)
+ )
+ .clickable(onClick = onClick)
+ )
+ Image(
+ imageVector = ImageVector.vectorResource(
+ if (isSelected) R.drawable.ic_checked else R.drawable.ic_unchecked
+ ),
+ contentDescription = if (isSelected) selectedDesc else unselectedDesc,
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(ImagePickerDimens.checkIconPadding)
+ .size(ImagePickerDimens.checkIconSize)
+ )
+ }
+}
+
+@Composable
+private fun ImagePickerDoneButton(onClick: () -> Unit) {
+ Button(
+ onClick = onClick,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = ImagePickerDimens.doneButtonTopPadding)
+ .navigationBarsPadding()
+ ) {
+ Text(stringResource(R.string.image_picker_done))
+ }
+}
+
+@Preview(showBackground = true, widthDp = 360, heightDp = 640)
+@Composable
+fun ImagePickerPreview() {
+ var selectedUris by remember { mutableStateOf(setOf()) }
+ ImagePickerContent(
+ modifier = Modifier
+ .height(640.dp)
+ .fillMaxWidth(),
+ imageUris = (1..9).map { index ->
+ Uri.parse("$PREVIEW_IMAGE_URL$index")
+ },
+ selectedUris = selectedUris,
+ onImageClick = { uri ->
+ selectedUris = if (selectedUris.contains(uri)) {
+ selectedUris - uri
+ } else {
+ selectedUris + uri
+ }
+ },
+ onDone = {}
+ )
+}
diff --git a/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/ImagePickerState.kt b/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/ImagePickerState.kt
new file mode 100644
index 0000000..81f9433
--- /dev/null
+++ b/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/ImagePickerState.kt
@@ -0,0 +1,13 @@
+package com.nexters.fooddiary.presentation.image
+
+import android.net.Uri
+import com.airbnb.mvrx.MavericksState
+import java.util.Collections.emptyList
+import java.util.Collections.emptySet
+
+data class ImagePickerState(
+ val imageUris: List = emptyList(),
+ val isLoading: Boolean = false,
+ val hasPermission: Boolean = false,
+ val selectedUris: Set = emptySet()
+) : MavericksState
diff --git a/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/ImagePickerViewModel.kt b/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/ImagePickerViewModel.kt
new file mode 100644
index 0000000..99e626d
--- /dev/null
+++ b/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/ImagePickerViewModel.kt
@@ -0,0 +1,86 @@
+package com.nexters.fooddiary.presentation.image
+
+import android.content.Context
+import android.net.Uri
+import com.airbnb.mvrx.MavericksViewModel
+import com.airbnb.mvrx.MavericksViewModelFactory
+import com.airbnb.mvrx.hilt.AssistedViewModelFactory
+import com.airbnb.mvrx.hilt.hiltMavericksViewModelFactory
+import com.nexters.fooddiary.core.common.permission.PermissionUtil
+import com.nexters.fooddiary.domain.usecase.GetTodayFoodPhotosUseCase
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.launch
+
+class ImagePickerViewModel @AssistedInject constructor(
+ @Assisted initialState: ImagePickerState,
+ @ApplicationContext private val context: Context,
+ private val getTodayFoodPhotosUseCase: GetTodayFoodPhotosUseCase
+) : MavericksViewModel(initialState) {
+
+ init {
+ checkPermission()
+ }
+
+ private fun checkPermission() {
+ val hasPermission = PermissionUtil.hasMediaPermission(context)
+ setState { copy(hasPermission = hasPermission) }
+
+ if (hasPermission) {
+ loadTodayFoodImages()
+ }
+ }
+
+ fun onPermissionGranted() {
+ setState { copy(hasPermission = true) }
+ loadTodayFoodImages()
+ }
+
+ private fun loadTodayFoodImages() {
+ viewModelScope.launch {
+ setState { copy(isLoading = true) }
+ try {
+ val uriStrings = getTodayFoodPhotosUseCase()
+ val uris = uriStrings.map { Uri.parse(it) }
+ setState {
+ copy(
+ imageUris = uris,
+ isLoading = false
+ )
+ }
+ } catch (e: Exception) {
+ setState {
+ copy(
+ imageUris = emptyList(),
+ isLoading = false
+ )
+ }
+ }
+ }
+ }
+
+ fun toggleImageSelection(uri: Uri) {
+ setState {
+ copy(
+ selectedUris = if (selectedUris.contains(uri)) {
+ selectedUris - uri
+ } else {
+ selectedUris + uri
+ }
+ )
+ }
+ }
+
+ fun clearSelection() {
+ setState { copy(selectedUris = emptySet()) }
+ }
+
+ @AssistedFactory
+ interface Factory : AssistedViewModelFactory {
+ override fun create(state: ImagePickerState): ImagePickerViewModel
+ }
+
+ companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory()
+}
diff --git a/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/di/ViewModelModule.kt b/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/di/ViewModelModule.kt
index 1f90f94..15a540d 100644
--- a/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/di/ViewModelModule.kt
+++ b/presentation/image/src/main/java/com/nexters/fooddiary/presentation/image/di/ViewModelModule.kt
@@ -4,6 +4,7 @@ import com.airbnb.mvrx.hilt.AssistedViewModelFactory
import com.airbnb.mvrx.hilt.MavericksViewModelComponent
import com.airbnb.mvrx.hilt.ViewModelKey
import com.nexters.fooddiary.presentation.image.ImageClassificationViewModel
+import com.nexters.fooddiary.presentation.image.ImagePickerViewModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@@ -16,4 +17,9 @@ interface ViewModelModule {
@IntoMap
@ViewModelKey(ImageClassificationViewModel::class)
fun imageClassificationViewModelFactory(factory: ImageClassificationViewModel.Factory): AssistedViewModelFactory<*, *>
+
+ @Binds
+ @IntoMap
+ @ViewModelKey(ImagePickerViewModel::class)
+ fun imagePickerViewModelFactory(factory: ImagePickerViewModel.Factory): AssistedViewModelFactory<*, *>
}
\ No newline at end of file
diff --git a/presentation/image/src/main/res/drawable/ic_checked.xml b/presentation/image/src/main/res/drawable/ic_checked.xml
new file mode 100644
index 0000000..4cf0ea5
--- /dev/null
+++ b/presentation/image/src/main/res/drawable/ic_checked.xml
@@ -0,0 +1,16 @@
+
+
+
+
diff --git a/presentation/image/src/main/res/drawable/ic_drawer.xml b/presentation/image/src/main/res/drawable/ic_drawer.xml
new file mode 100644
index 0000000..c708507
--- /dev/null
+++ b/presentation/image/src/main/res/drawable/ic_drawer.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/presentation/image/src/main/res/drawable/ic_unchecked.xml b/presentation/image/src/main/res/drawable/ic_unchecked.xml
new file mode 100644
index 0000000..ba4374f
--- /dev/null
+++ b/presentation/image/src/main/res/drawable/ic_unchecked.xml
@@ -0,0 +1,16 @@
+
+
+
+
diff --git a/presentation/image/src/main/res/drawable/icon_back.xml b/presentation/image/src/main/res/drawable/icon_back.xml
new file mode 100644
index 0000000..0a61378
--- /dev/null
+++ b/presentation/image/src/main/res/drawable/icon_back.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/presentation/image/src/main/res/values/strings.xml b/presentation/image/src/main/res/values/strings.xml
index 33caa00..1029b58 100644
--- a/presentation/image/src/main/res/values/strings.xml
+++ b/presentation/image/src/main/res/values/strings.xml
@@ -12,4 +12,16 @@
음식입니다! (신뢰도: %1$s)
음식이 아닙니다. (신뢰도: %1$s)
image/*
+
+
+ 뒤로가기
+ 이미지 아이콘
+ 이미지 접근 권한이 필요합니다
+ 권한 요청
+ 로딩 중…
+ 이미지가 없습니다
+ 갤러리 이미지
+ 선택됨
+ 선택 안됨
+ 추가하기