-
Notifications
You must be signed in to change notification settings - Fork 0
[FD-79] image picker 추가 #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
cf2f312
17b28f4
c156dca
46d557a
e1a7642
1f2ff66
1f6e692
e06fafe
d429643
f4fdf19
6ae22cc
2e52ae5
bdf2c37
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| <?xml version="1.0" encoding="utf-8"?> | ||
| <resources> | ||
|
|
||
| <style name="Theme.FoodDiary" parent="android:Theme.Material.Light.NoActionBar" /> | ||
| <style name="Theme.FoodDiary" parent="android:Theme.Material.NoActionBar.Fullscreen" /> | ||
| </resources> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ) | ||
| } | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String, FoodClassificationResult>() | ||
|
|
||
| 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 | ||
| ) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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? | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String> { | ||
| 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) | ||
|
Comment on lines
+25
to
+27
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. p2. async await으로 처리하면 비동기 처리를 더 효율적으로 사용할 수 있을 것 같아요 |
||
| } | ||
|
|
||
| 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? | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. p5. AI 사용하고 저도 자주하는 실수... import 부탁드리겠습니다..! |
||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String, ClassificationResult>() | ||
|
|
||
| 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 | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. p4. 버전을 변경했어야하는 이유가 있었나요? |
||
| 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" } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
color scheme이름은 일단 아무거나 끼워뒀습니다! 추후 맞춰 변경 예정입니다