Skip to content
Open
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
2 changes: 1 addition & 1 deletion app/src/main/res/values/themes.xml
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>
19 changes: 17 additions & 2 deletions core/ui/src/main/java/com/nexters/fooddiary/core/ui/theme/Theme.kt
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

color scheme이름은 일단 아무거나 끼워뒀습니다! 추후 맞춰 변경 예정입니다

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
)
}

1 change: 1 addition & 0 deletions data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ android {
dependencies {
// Modules
implementation(projects.core.common)
implementation(projects.core.classification)
implementation(projects.domain)

// Coroutines
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -32,4 +34,10 @@ abstract class MediaRepositoryModule {
internal abstract fun bindMediaRepository(
mediaRepositoryImpl: MediaRepositoryImpl
): MediaRepository

@Binds
@Singleton
internal abstract fun bindClassificationRepository(
classificationRepositoryImpl: ClassificationRepositoryImpl
): ClassificationRepository
}
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
Expand Up @@ -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<LocalDate, List<MediaItem>>

/**
* 특정 월의 날짜별 사진 개수를 반환
*/
suspend fun getPhotoCountByDate(yearMonth: YearMonth): Map<LocalDate, Int>

/**
* 전체 앨범의 모든 사진을 날짜별로 그룹화하여 반환
* 성능 비교를 위한 전체 스캔 함수
*/
suspend fun getAllPhotos(): Map<LocalDate, List<MediaItem>>

/**
* 전체 앨범의 날짜별 사진 개수를 반환
* 성능 비교를 위한 전체 스캔 함수
*/
suspend fun getAllPhotoCountByDate(): Map<LocalDate, Int>
}
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
Copy link
Collaborator

Choose a reason for hiding this comment

The 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?
Copy link
Collaborator

Choose a reason for hiding this comment

The 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
}
}
}
10 changes: 9 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Copy link
Collaborator

Choose a reason for hiding this comment

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

p4. 버전을 변경했어야하는 이유가 있었나요?

hilt = "2.58"
hiltNavigationCompose = "1.3.0"
retrofit = "3.0.0"
Expand All @@ -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" }
Expand Down Expand Up @@ -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" }
Expand Down
8 changes: 8 additions & 0 deletions presentation/image/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
}

Loading