From 158a33274e964ffc2ef8f03b2daa29adcc5c4e1d Mon Sep 17 00:00:00 2001 From: Rainxch Zed Date: Sun, 4 Jan 2026 17:34:04 +0500 Subject: [PATCH 1/2] feat(search): Sync installed apps on ViewModel init This commit introduces an initial synchronization of installed applications when the `SearchViewModel` is created. Changes include: * Injecting `SyncInstalledAppsUseCase` into `SearchViewModel`. * Calling the use case upon ViewModel initialization to ensure the app state is up-to-date. * Refactoring the search result mapping to use the `isUpdateAvailable` property from the app model directly, removing redundant update checks. --- .../githubstore/app/di/SharedModules.kt | 3 +- .../search/presentation/SearchViewModel.kt | 48 ++++++++++--------- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/SharedModules.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/SharedModules.kt index 6e4764f61..c7de3b6ad 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/SharedModules.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/SharedModules.kt @@ -192,7 +192,8 @@ val searchModule: Module = module { viewModel { SearchViewModel( searchRepository = get(), - installedAppsRepository = get() + installedAppsRepository = get(), + syncInstalledAppsUseCase = get() ) } } diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/search/presentation/SearchViewModel.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/search/presentation/SearchViewModel.kt index c0da1283a..84adebc10 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/search/presentation/SearchViewModel.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/search/presentation/SearchViewModel.kt @@ -20,11 +20,13 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import zed.rainxch.githubstore.core.domain.repository.InstalledAppsRepository +import zed.rainxch.githubstore.core.domain.use_cases.SyncInstalledAppsUseCase import zed.rainxch.githubstore.feature.search.domain.repository.SearchRepository class SearchViewModel( private val searchRepository: SearchRepository, - private val installedAppsRepository: InstalledAppsRepository + private val installedAppsRepository: InstalledAppsRepository, + private val syncInstalledAppsUseCase: SyncInstalledAppsUseCase ) : ViewModel() { private var currentSearchJob: Job? = null @@ -35,9 +37,23 @@ class SearchViewModel( val state = _state.asStateFlow() init { + syncSystemState() observeInstalledApps() } + private fun syncSystemState() { + viewModelScope.launch { + try { + val result = syncInstalledAppsUseCase() + if (result.isFailure) { + Logger.w { "Initial sync had issues: ${result.exceptionOrNull()?.message}" } + } + } catch (e: Exception) { + Logger.e { "Initial sync failed: ${e.message}" } + } + } + } + private fun observeInstalledApps() { viewModelScope.launch { installedAppsRepository.getAllInstalledApps().collect { installedApps -> @@ -99,21 +115,13 @@ class SearchViewModel( .collect { paginatedRepos -> currentPage = paginatedRepos.nextPageIndex - val newReposWithStatus = coroutineScope { - paginatedRepos.repos.map { repo -> - async(Dispatchers.IO) { - val app = installedMap[repo.id] - val isUpdateAvailable = if (app?.packageName != null) { - installedAppsRepository.checkForUpdates(app.packageName) - } else false - - SearchRepo( - isInstalled = app != null, - isUpdateAvailable = isUpdateAvailable, - repo = repo - ) - } - }.awaitAll() + val newReposWithStatus = paginatedRepos.repos.map { repo -> + val app = installedMap[repo.id] + SearchRepo( + isInstalled = app != null, + isUpdateAvailable = app?.isUpdateAvailable ?: false, + repo = repo + ) } _state.update { currentState -> @@ -183,9 +191,7 @@ class SearchViewModel( is SearchAction.OnLanguageSelected -> { if (_state.value.selectedLanguage != action.language) { _state.update { - it.copy( - selectedLanguage = action.language - ) + it.copy(selectedLanguage = action.language) } currentPage = 1 searchDebounceJob?.cancel() @@ -225,9 +231,7 @@ class SearchViewModel( SearchAction.OnToggleLanguageSheetVisibility -> { _state.update { - it.copy( - isLanguageSheetVisible = !it.isLanguageSheetVisible - ) + it.copy(isLanguageSheetVisible = !it.isLanguageSheetVisible) } } From 89823e1f4b2e5d2ae76dd1aedbaef4055fb5586c Mon Sep 17 00:00:00 2001 From: Rainxch Zed Date: Sun, 4 Jan 2026 17:34:20 +0500 Subject: [PATCH 2/2] feat(search): Trigger pagination when empty space is visible This commit enhances the pagination logic in the search results screen. Previously, pagination was only triggered by scrolling near the end of the list. Now, if the initial search results do not fill the entire viewport, leaving empty space at the bottom, a subsequent fetch for more data is automatically triggered to ensure the screen is fully populated with content. This improves the user experience for queries that return a small number of results per page. --- .../feature/search/presentation/SearchRoot.kt | 79 +++++++++---------- 1 file changed, 38 insertions(+), 41 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/search/presentation/SearchRoot.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/search/presentation/SearchRoot.kt index a49362081..4e13f80a7 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/search/presentation/SearchRoot.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/search/presentation/SearchRoot.kt @@ -64,6 +64,7 @@ import githubstore.composeapp.generated.resources.results_found import githubstore.composeapp.generated.resources.retry import githubstore.composeapp.generated.resources.search_repositories_hint import githubstore.composeapp.generated.resources.sort_by +import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview import org.koin.compose.viewmodel.koinViewModel @@ -130,16 +131,25 @@ fun SearchScreen( derivedStateOf { val layoutInfo = listState.layoutInfo val totalItems = layoutInfo.totalItemsCount - val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull() + val visibleItems = layoutInfo.visibleItemsInfo + + if (totalItems == 0 || + state.isLoadingMore || + state.isLoading || + !state.hasMorePages) { + return@derivedStateOf false + } + + val lastVisibleItem = visibleItems.lastOrNull() ?: return@derivedStateOf false + val viewportEndOffset = layoutInfo.viewportEndOffset + + val hasEmptySpaceAtBottom = lastVisibleItem.index == totalItems - 1 && + lastVisibleItem.offset.y + lastVisibleItem.size.height < viewportEndOffset val threshold = (totalItems * 0.8f).toInt() + val isNearEnd = lastVisibleItem.index >= threshold - totalItems > 0 && - lastVisibleItem != null && - lastVisibleItem.index >= threshold && - !state.isLoadingMore && - !state.isLoading && - state.hasMorePages + isNearEnd || hasEmptySpaceAtBottom } } @@ -151,6 +161,27 @@ fun SearchScreen( } } + LaunchedEffect(listState.layoutInfo.totalItemsCount, listState.layoutInfo.viewportEndOffset) { + val layoutInfo = listState.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + val lastVisible = visibleItems.lastOrNull() + + if (lastVisible != null && + layoutInfo.totalItemsCount > 0 && + !state.isLoadingMore && + !state.isLoading && + state.hasMorePages) { + + val hasEmptySpace = lastVisible.index == layoutInfo.totalItemsCount - 1 && + lastVisible.offset.y + lastVisible.size.height < layoutInfo.viewportEndOffset + + if (hasEmptySpace) { + delay(100) + currentOnAction(SearchAction.LoadMore) + } + } + } + LaunchedEffect(Unit) { if (state.query.isEmpty()) { focusRequester.requestFocus() @@ -267,40 +298,6 @@ fun SearchScreen( ) } - if (false) { // FOR NOW SORTING FEATURE IS NOT AVAILABLE - Row( - modifier = Modifier - .align(Alignment.End) - .clickable { - - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) - ) { - Text( - text = stringResource(Res.string.sort_by) + ": ${state.selectedSortBy.displayText()}", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.outline, - fontWeight = FontWeight.Bold - ) - - Icon( - imageVector = Icons.Outlined.KeyboardArrowDown, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.outline, - ) - } - SortByBottomSheet( - sortByOptions = SortBy.entries.toList(), - selectedSortBy = state.selectedSortBy, - onSortBySelected = { chosen -> - onAction(SearchAction.OnSortBySelected(chosen)) - }, - onDismissRequest = { } - ) - } - Box(Modifier.fillMaxSize()) { if (state.isLoading && state.repositories.isEmpty()) { Box(