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
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ RELEASE_AI_SERVER_URL=
- Room
- Splash
- WorkManager
- Paging3

### UI
- Jetpack Compose (Declarative UI framework)
Expand Down Expand Up @@ -80,28 +81,28 @@ RELEASE_AI_SERVER_URL=
├── build-logic # Convention Plugins (Gradle 공통 설정 관리)
├── core # 공통 기능 모듈 (Shared Modules)
│ ├── common # 유틸리티, 상수, 공통 코드
│ ├── data
│ │ ├── api
│ │ └── impl
│ ├── data
│ │ ├── api
│ │ └── impl
│ ├── datastore # Preference DataStore 관리
│ │ ├── api
│ │ └── impl
│ ├── designsystem # 공통 컴포넌트 및 테마
│ ├── model # 도메인 모델
│ ├── model # 도메인 모델
│ ├── network # Retrofit 설정 및 네트워크 서비스
│ ├── notification # 알림(Notification) 생성 및 관리
│ ├── room # Room 로컬 데이터베이스 설정
│ │ ├── api # DAO 인터페이스
│ │ └── impl # Database 생성 및 Migration 로직
│ └── ui
│ └── ui
├── feature # 화면 단위 기능 모듈 (Feature Modules)
│ ├── detail
│ ├── detail
│ ├── graph # 갤러리 전체 데이터 시각화 화면
│ ├── home # 홈 화면 (이미지 분석 워커 포함)
│ ├── main
│ ├── main
│ ├── person # 인물 사진 모아보기 및 관리
│ ├── screens # 메인 네비게이션 및 스크린 정의
│ ├── search # AI 기반 자연어 및 포커싱 검색 화면
│ └── splash
│ └── splash
└── gradle # Version Catalog (libs.versions.toml)
```
1 change: 1 addition & 0 deletions core/data/api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ dependencies {
implementation(projects.core.model)

api(libs.kotlinx.coroutines.core)
api(libs.androidx.compose.paging)
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package com.example.metasearch.core.data.api.repository

import android.net.Uri
import androidx.paging.PagingData
import com.example.metasearch.core.model.GalleryImageModel
import kotlinx.coroutines.flow.Flow

interface GalleryRepository {
fun getGalleryPagingData(): Flow<PagingData<GalleryImageModel>>

suspend fun getAllGalleryImages(): List<Uri>
suspend fun getFileName(uri: Uri): String?
suspend fun findMatchedUri(photoName: String): Uri?
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.example.metasearch.core.data.impl.datasource

import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.provider.MediaStore
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.example.metasearch.core.model.GalleryImageModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext

class GalleryPagingSource(
private val context: Context,
private val ioDispatcher: CoroutineDispatcher,
) : PagingSource<Int, GalleryImageModel>() {

override suspend fun load(params: LoadParams<Int>): LoadResult<Int, GalleryImageModel> {
return withContext(ioDispatcher) {
try {
val offset = params.key ?: 0
val limit = params.loadSize
val imageList = mutableListOf<GalleryImageModel>()

val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DATE_ADDED,
)

val queryArgs = android.os.Bundle().apply {
putInt(ContentResolver.QUERY_ARG_LIMIT, limit)
putInt(ContentResolver.QUERY_ARG_OFFSET, offset)
putStringArray(ContentResolver.QUERY_ARG_SORT_COLUMNS, arrayOf(MediaStore.Images.Media.DATE_ADDED))
putInt(ContentResolver.QUERY_ARG_SORT_DIRECTION, ContentResolver.QUERY_SORT_DIRECTION_DESCENDING)
}

context.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
queryArgs,
null,
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)

while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val date = cursor.getLong(dateColumn)
val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
imageList.add(GalleryImageModel(id = id, uriString = uri.toString(), dateAdded = date))
}
}

LoadResult.Page(
data = imageList,
prevKey = if (offset == 0) null else offset - limit,
nextKey = if (imageList.size < limit) null else offset + imageList.size,
)
Comment on lines +54 to +58
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

Potential negative prevKey value.

When offset < limit (e.g., on initial load with initialLoadSize=60 but pageSize=30), prevKey = offset - limit would produce a negative value. This should be clamped to avoid invalid page keys.

🐛 Proposed fix
                 LoadResult.Page(
                     data = imageList,
-                    prevKey = if (offset == 0) null else offset - limit,
+                    prevKey = if (offset == 0) null else (offset - limit).coerceAtLeast(0).takeIf { it < offset },
                     nextKey = if (imageList.size < limit) null else offset + imageList.size,
                 )

Alternatively, a simpler approach:

-                    prevKey = if (offset == 0) null else offset - limit,
+                    prevKey = if (offset == 0) null else maxOf(0, offset - limit),
📝 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
LoadResult.Page(
data = imageList,
prevKey = if (offset == 0) null else offset - limit,
nextKey = if (imageList.size < limit) null else offset + imageList.size,
)
LoadResult.Page(
data = imageList,
prevKey = if (offset == 0) null else (offset - limit).coerceAtLeast(0).takeIf { it < offset },
nextKey = if (imageList.size < limit) null else offset + imageList.size,
)
🤖 Prompt for AI Agents
In
@core/data/impl/src/main/java/com/example/metasearch/core/data/impl/datasource/GalleryPagingSource.kt
around lines 54 - 58, The prevKey calculation in GalleryPagingSource's
LoadResult.Page can produce negative keys when offset < limit; update the logic
that sets prevKey (currently "if (offset == 0) null else offset - limit") to
clamp or guard against negatives — e.g., compute prev = offset - limit and set
prevKey = null if prev <= 0 (or prevKey = max(0, prev) if your loader accepts 0)
so prevKey is never negative; adjust in the same method where LoadResult.Page is
returned.

} catch (e: Exception) {
LoadResult.Error(e)
}
}
}

override fun getRefreshKey(state: PagingState<Int, GalleryImageModel>): Int? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(state.config.pageSize)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(state.config.pageSize)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,38 @@ import android.content.ContentUris
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.example.metasearch.core.data.api.repository.GalleryRepository
import com.example.metasearch.core.data.impl.datasource.GalleryPagingSource
import com.example.metasearch.core.data.impl.di.IoDispatcher
import com.example.metasearch.core.model.GalleryImageModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
internal class GalleryRepositoryImpl @Inject constructor(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@ApplicationContext private val context: Context,
) : GalleryRepository {

override fun getGalleryPagingData(): Flow<PagingData<GalleryImageModel>> {
return Pager(
config = PagingConfig(
pageSize = 30,
enablePlaceholders = false,
initialLoadSize = 60,
),
pagingSourceFactory = { GalleryPagingSource(context, ioDispatcher) },
).flow
}

override suspend fun getAllGalleryImages(): List<Uri> = withContext(Dispatchers.IO) {
val imageUris = mutableListOf<Uri>()
val projection = arrayOf(MediaStore.Images.Media._ID)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.metasearch.core.model

import androidx.compose.runtime.Stable

@Stable
data class GalleryImageModel(
val id: Long, // MediaStore._ID
val uriString: String,
val dateAdded: Long,
)

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.example.metasearch.feature.home

import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
Expand All @@ -10,6 +9,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.paging.cachedIn
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
Expand Down Expand Up @@ -50,7 +50,6 @@ class HomePresenter @AssistedInject constructor(
val scope = rememberCoroutineScope()
val context = LocalContext.current

var isGalleryLoading by remember { mutableStateOf(false) }
var isPersonLoading by remember { mutableStateOf(false) }
val isAnalyzing by remember(context) {
imageAnalysisRepository.getAnalysisStatus(context)
Expand All @@ -60,10 +59,8 @@ class HomePresenter @AssistedInject constructor(
val localPersons by personRepository.getHomeDisplayPersons().collectAsState(initial = emptyList())
var displayPersons by remember { mutableStateOf<List<PersonModel>>(emptyList()) }

var images by remember { mutableStateOf<List<Uri>>(emptyList()) }

LaunchedEffect(Unit) {
images = galleryRepository.getAllGalleryImages()
val galleryPagingFlow = remember {
galleryRepository.getGalleryPagingData().cachedIn(scope)
}

LaunchedEffect(localPersons) {
Expand Down Expand Up @@ -117,12 +114,11 @@ class HomePresenter @AssistedInject constructor(
}

return HomeUiState(
isGalleryLoading = isGalleryLoading,
isPersonLoading = isPersonLoading,
isAnalyzing = isAnalyzing,
isExpanded = isExpanded,
persons = displayPersons,
images = images,
images = galleryPagingFlow,
eventSink = ::handleEvent,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ 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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyRow
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.lazy.items
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
Expand All @@ -27,10 +28,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.paging.LoadState
import androidx.paging.PagingData.Companion.from
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import com.example.metasearch.core.designsystem.annotation.DevicePreview
import com.example.metasearch.core.designsystem.theme.MetaSearchTheme
import com.example.metasearch.core.designsystem.theme.Neutral500
import com.example.metasearch.core.model.GalleryImageModel
import com.example.metasearch.core.ui.MetaSearchScaffold
import com.example.metasearch.core.ui.component.MetaSearchLoadingIndicator
import com.example.metasearch.core.ui.component.MetaSearchSquareImage
Expand All @@ -41,6 +46,7 @@ import com.example.metasearch.feature.screens.component.MetaSearchMainBottomBar
import com.example.metasearch.feature.screens.component.MetaSearchMainTabItem
import com.slack.circuit.codegen.annotations.CircuitInject
import dagger.hilt.android.components.ActivityRetainedComponent
import kotlinx.coroutines.flow.flowOf

@CircuitInject(HomeScreen::class, ActivityRetainedComponent::class)
@Composable
Expand All @@ -50,9 +56,11 @@ fun HomeUi(
) {
MetaSearchScaffold(
modifier = modifier.fillMaxSize(),
contentWindowInsets = WindowInsets(bottom = 0),
bottomBar = {
MetaSearchMainBottomBar(
modifier = modifier,
modifier = modifier
.padding(bottom = MetaSearchTheme.spacing.spacing3),
Comment on lines +62 to +63
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

Avoid reusing the outer modifier parameter.

The modifier parameter from HomeUi is being passed to MetaSearchMainBottomBar. This can cause unintended side effects since the caller's modifiers (e.g., fillMaxSize()) would be applied to the bottom bar. Use a fresh Modifier instead.

🐛 Proposed fix
             MetaSearchMainBottomBar(
-                modifier = modifier
+                modifier = Modifier
                     .padding(bottom = MetaSearchTheme.spacing.spacing3),
📝 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
modifier = modifier
.padding(bottom = MetaSearchTheme.spacing.spacing3),
modifier = Modifier
.padding(bottom = MetaSearchTheme.spacing.spacing3),
🤖 Prompt for AI Agents
In @feature/home/src/main/java/com/example/metasearch/feature/home/HomeUi.kt
around lines 62 - 63, HomeUi currently reuses the incoming modifier when
composing MetaSearchMainBottomBar (passing modifier = modifier.padding(...)),
which can apply callers' modifiers to the bottom bar; change the call to use a
fresh Modifier instance instead (e.g., pass modifier =
Modifier.padding(MetaSearchTheme.spacing.spacing3)) so MetaSearchMainBottomBar
gets only its intended local modifiers; locate the usage in HomeUi where
MetaSearchMainBottomBar is invoked and replace the reused `modifier` with a new
`Modifier` combined with the padding.

currentTab = MetaSearchMainTabItem.HOME,
onTabSelected = {
state.eventSink(HomeUiEvent.OnTabClick(it.screen))
Expand All @@ -72,8 +80,12 @@ private fun HomeUiContent(
state: HomeUiState,
innerPadding: PaddingValues,
) {
val lazyPagingItems = state.images.collectAsLazyPagingItems()

Column(
modifier = Modifier.padding(innerPadding),
modifier = Modifier
.fillMaxSize()
.statusBarsPadding(),
) {
HomeHeader(
onUploadClick = {
Expand Down Expand Up @@ -145,28 +157,37 @@ private fun HomeUiContent(
modifier = Modifier.padding(MetaSearchTheme.spacing.spacing2),
text = stringResource(
R.string.home_screen_gallery_grid_view_title,
state.images.size,
lazyPagingItems.itemCount,
),
color = Neutral500,
)
Box(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.weight(1f),
) {
LazyVerticalGrid(
modifier = Modifier.fillMaxSize(),
columns = GridCells.Fixed(5),
contentPadding = PaddingValues(
bottom = innerPadding.calculateBottomPadding() + 16.dp,
),
) {
items(state.images) { uri ->
MetaSearchSquareImage(
model = uri,
onClick = {
state.eventSink(HomeUiEvent.OnImageClick(uri.toString()))
},
)
items(
count = lazyPagingItems.itemCount,
key = lazyPagingItems.itemKey { it.id },
) { index ->
val item = lazyPagingItems[index]
if (item != null) {
MetaSearchSquareImage(
model = item.uriString,
onClick = {
state.eventSink(HomeUiEvent.OnImageClick(item.uriString))
},
)
}
}
}

if (state.isGalleryLoading) {
if (lazyPagingItems.loadState.refresh is LoadState.Loading) {
MetaSearchLoadingIndicator(modifier = Modifier.align(Alignment.Center))
}
}
Expand All @@ -177,6 +198,14 @@ private fun HomeUiContent(
@Composable
private fun HomeUiPreview() {
MetaSearchTheme {
val fakeImages = List(20) { index ->
GalleryImageModel(
id = index.toLong(),
uriString = "android.resource://com.example.metasearch/drawable/ic_launcher_foreground",
dateAdded = System.currentTimeMillis(),
)
}

HomeUi(
state = HomeUiState(
isExpanded = true,
Expand Down Expand Up @@ -225,9 +254,7 @@ private fun HomeUiPreview() {
// isHomeDisplay = true,
// ),
// ),
images = List(20) {
"android.resource://com.example.metasearch/feature/home/drawable/ic_launcher_foreground".toUri()
},
images = flowOf(from(fakeImages)),
eventSink = {},
),
)
Expand Down
Loading