From 2a0604a83f29473fa4c5a4083be28f58630a8b0a Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Fri, 17 Apr 2026 18:22:05 +0500 Subject: [PATCH] feat(search): add "Explore from GitHub" functionality - Introduce `BackendExploreResponse` DTO and `ExploreResult` domain model to handle paginated results from the backend's explore endpoint. - Extend `SearchRepository` and its implementation to support fetching additional repositories directly from GitHub via the backend. - Update `SearchViewModel` to manage explore state, including pagination, loading status, and deduplication of results against existing search items. - Enhance the search UI with an `ExploreFromGithubButton` and integrated loading/exhausted states. - Add localized strings for the new explore features in English, Arabic, Bengali, Spanish, French, Hindi, Italian, Japanese, Korean, Polish, Russian, Turkish, and Simplified Chinese. - Refactor `SearchState` to include `visibleRepos` as a managed property and introduce an `ExploreStatus` enum. --- .../core/data/dto/BackendExploreResponse.kt | 10 + .../core/data/network/BackendApiClient.kt | 21 ++ .../composeResources/values-ar/strings-ar.xml | 4 + .../composeResources/values-bn/strings-bn.xml | 4 + .../composeResources/values-es/strings-es.xml | 4 + .../composeResources/values-fr/strings-fr.xml | 4 + .../composeResources/values-hi/strings-hi.xml | 4 + .../composeResources/values-it/strings-it.xml | 4 + .../composeResources/values-ja/strings-ja.xml | 4 + .../composeResources/values-ko/strings-ko.xml | 4 + .../composeResources/values-pl/strings-pl.xml | 4 + .../composeResources/values-ru/strings-ru.xml | 4 + .../composeResources/values-tr/strings-tr.xml | 4 + .../values-zh-rCN/strings-zh-rCN.xml | 4 + .../composeResources/values/strings.xml | 6 + .../data/repository/SearchRepositoryImpl.kt | 27 +++ .../zed/rainxch/domain/model/ExploreResult.kt | 9 + .../domain/repository/SearchRepository.kt | 7 + .../search/presentation/SearchAction.kt | 2 + .../rainxch/search/presentation/SearchRoot.kt | 183 +++++++++++------- .../search/presentation/SearchState.kt | 10 +- .../search/presentation/SearchViewModel.kt | 95 ++++++++- 22 files changed, 349 insertions(+), 69 deletions(-) create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/BackendExploreResponse.kt create mode 100644 feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/model/ExploreResult.kt diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/BackendExploreResponse.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/BackendExploreResponse.kt new file mode 100644 index 000000000..23acea604 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/BackendExploreResponse.kt @@ -0,0 +1,10 @@ +package zed.rainxch.core.data.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class BackendExploreResponse( + val items: List, + val page: Int, + val hasMore: Boolean, +) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt index 04a0f2c95..acbac77ac 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt @@ -10,6 +10,8 @@ import io.ktor.client.request.parameter import io.ktor.http.isSuccess import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json +import io.ktor.client.plugins.timeout +import zed.rainxch.core.data.dto.BackendExploreResponse import zed.rainxch.core.data.dto.BackendRepoResponse import zed.rainxch.core.data.dto.BackendSearchResponse import kotlin.coroutines.cancellation.CancellationException @@ -73,6 +75,25 @@ class BackendApiClient { } } + suspend fun searchExplore( + query: String, + platform: String? = null, + page: Int = 1, + ): Result = + safeCall { + val response = httpClient.get("search/explore") { + parameter("q", query) + if (platform != null) parameter("platform", platform) + parameter("page", page) + timeout { requestTimeoutMillis = 20_000 } + } + if (response.status.isSuccess()) { + Result.success(response.body()) + } else { + Result.failure(BackendException("HTTP ${response.status.value}")) + } + } + suspend fun getRepo(owner: String, name: String): Result = safeCall { val response = httpClient.get("repo/$owner/$name") diff --git a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml index 5c07c233c..21be4893a 100644 --- a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml +++ b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml @@ -235,6 +235,10 @@ التنزيلات الترخيص لا يوجد + جلب المزيد من GitHub + جارٍ الجلب من GitHub… + لا توجد نتائج أخرى على GitHub + فشل الجلب من GitHub. حاول مرة أخرى. بواسطة %1$s diff --git a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml index 732cab0aa..28dd5ff8d 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -194,6 +194,10 @@ ডাউনলোড লাইসেন্স নেই + GitHub থেকে আরও আনুন + GitHub থেকে আনা হচ্ছে… + GitHub-এ আর ফলাফল নেই + GitHub থেকে আনতে ব্যর্থ। আবার চেষ্টা করুন। %1$s দ্বারা diff --git a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml index 877646139..607af897d 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -144,6 +144,10 @@ Descargas Licencia Ninguna + Buscar más en GitHub + Buscando en GitHub… + No hay más resultados en GitHub + Error al buscar en GitHub. Inténtalo de nuevo. por %1$s • Instalado: %1$s diff --git a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml index 393b125c6..e444533c2 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -144,6 +144,10 @@ Téléchargements Licence Aucune + Chercher plus sur GitHub + Recherche sur GitHub… + Plus de résultats sur GitHub + Échec de la recherche GitHub. Réessayez. par %1$s • Installé : %1$s diff --git a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml index 643c02ef0..36bed161e 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -194,6 +194,10 @@ डाउनलोड लाइसेंस कोई नहीं + GitHub से और लाएं + GitHub से ला रहे हैं… + GitHub पर और परिणाम नहीं हैं + GitHub से लाने में विफल। पुनः प्रयास करें। द्वारा %1$s diff --git a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml index d9dc2222f..de1ebcbce 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -194,6 +194,10 @@ Download Licenza Nessuna + Cerca altri su GitHub + Ricerca su GitHub… + Nessun altro risultato su GitHub + Ricerca GitHub fallita. Riprova. per %1$s diff --git a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml index e00a5bc2f..378f3e2f1 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -145,6 +145,10 @@ ダウンロード ライセンス なし + GitHubからさらに取得 + GitHubから取得中… + GitHubにこれ以上の結果はありません + GitHubからの取得に失敗しました。再試行してください。 %1$s 作 • インストール済み: %1$s diff --git a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml index 719052335..7474390b9 100644 --- a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml +++ b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml @@ -192,6 +192,10 @@ 다운로드 라이선스 없음 + GitHub에서 더 가져오기 + GitHub에서 가져오는 중… + GitHub에 더 이상 결과가 없습니다 + GitHub에서 가져오기 실패. 다시 시도하세요. %1$s 작성 diff --git a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml index e3f916b95..76e6f3494 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -163,6 +163,10 @@ Pobrania Licencja Brak + Pobierz więcej z GitHub + Pobieranie z GitHub… + Brak więcej wyników na GitHub + Nie udało się pobrać z GitHub. Spróbuj ponownie. autor: %1$s • Zainstalowana: %1$s diff --git a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml index dc599567d..b47c72406 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -159,6 +159,10 @@ Загрузки Лицензия Нет + Найти ещё на GitHub + Поиск на GitHub… + Больше результатов на GitHub нет + Не удалось загрузить с GitHub. Попробуйте снова. от %1$s • Установлено: %1$s diff --git a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml index 87a930225..ff71a6b27 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -193,6 +193,10 @@ İndirmeler Lisans Yok + GitHub\'tan daha fazla getir + GitHub\'tan getiriliyor… + GitHub\'ta başka sonuç yok + GitHub\'tan getirilemedi. Tekrar deneyin. %1$s diff --git a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml index 3b44685d7..311726d56 100644 --- a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml +++ b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml @@ -146,6 +146,10 @@ 下载量 许可证 + 从 GitHub 获取更多 + 正在从 GitHub 获取… + GitHub 上没有更多结果 + 从 GitHub 获取失败,请重试。 由 %1$s • 已安装:%1$s diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 36cf3e958..8aee1f624 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -244,6 +244,12 @@ License None + + Fetch more from GitHub + Fetching from GitHub… + No more results on GitHub + Failed to fetch from GitHub. Try again. + by %1$s • Installed: %1$s diff --git a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt index 5dae769fb..fc6099a25 100644 --- a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt +++ b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt @@ -29,6 +29,7 @@ import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.domain.model.GithubRepoSummary import zed.rainxch.core.domain.model.PaginatedDiscoveryRepositories import zed.rainxch.core.domain.model.RateLimitException +import zed.rainxch.domain.model.ExploreResult import zed.rainxch.domain.model.ProgrammingLanguage import zed.rainxch.domain.model.SortBy import zed.rainxch.domain.model.SortOrder @@ -426,4 +427,30 @@ class SearchRepositoryImpl( } return result } + + override suspend fun exploreFromGithub( + query: String, + platform: DiscoveryPlatform, + page: Int, + ): ExploreResult { + val platformSlug = when (platform) { + DiscoveryPlatform.Android -> "android" + DiscoveryPlatform.Windows -> "windows" + DiscoveryPlatform.Macos -> "macos" + DiscoveryPlatform.Linux -> "linux" + DiscoveryPlatform.All -> null + } + + val response = backendApiClient.searchExplore( + query = query, + platform = platformSlug, + page = page, + ).getOrThrow() + + return ExploreResult( + repos = response.items.map { it.toSummary() }, + page = response.page, + hasMore = response.hasMore, + ) + } } diff --git a/feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/model/ExploreResult.kt b/feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/model/ExploreResult.kt new file mode 100644 index 000000000..580d7d618 --- /dev/null +++ b/feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/model/ExploreResult.kt @@ -0,0 +1,9 @@ +package zed.rainxch.domain.model + +import zed.rainxch.core.domain.model.GithubRepoSummary + +data class ExploreResult( + val repos: List, + val page: Int, + val hasMore: Boolean, +) diff --git a/feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/repository/SearchRepository.kt b/feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/repository/SearchRepository.kt index 7430ea86d..06400e77f 100644 --- a/feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/repository/SearchRepository.kt +++ b/feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/repository/SearchRepository.kt @@ -3,6 +3,7 @@ package zed.rainxch.domain.repository import kotlinx.coroutines.flow.Flow import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.domain.model.PaginatedDiscoveryRepositories +import zed.rainxch.domain.model.ExploreResult import zed.rainxch.domain.model.ProgrammingLanguage import zed.rainxch.domain.model.SortBy import zed.rainxch.domain.model.SortOrder @@ -16,4 +17,10 @@ interface SearchRepository { sortOrder: SortOrder, page: Int, ): Flow + + suspend fun exploreFromGithub( + query: String, + platform: DiscoveryPlatform, + page: Int, + ): ExploreResult } diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchAction.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchAction.kt index 25e300779..45eae49a3 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchAction.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchAction.kt @@ -71,4 +71,6 @@ sealed interface SearchAction { ) : SearchAction data object OnClearAllHistory : SearchAction + + data object ExploreFromGithub : SearchAction } diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt index 6ba350f0a..2ae6e3238 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid @@ -39,6 +40,7 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.material.icons.outlined.TravelExplore import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -46,6 +48,7 @@ import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilterChip +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -211,7 +214,7 @@ fun SearchScreen( val hasEmptySpaceAtBottom = lastVisibleItem.index == totalItems - 1 && - lastVisibleItem.offset.y + lastVisibleItem.size.height < viewportEndOffset + lastVisibleItem.offset.y + lastVisibleItem.size.height < viewportEndOffset val threshold = (totalItems * 0.8f).toInt() val isNearEnd = lastVisibleItem.index >= threshold @@ -241,7 +244,7 @@ fun SearchScreen( ) { val hasEmptySpace = lastVisible.index == layoutInfo.totalItemsCount - 1 && - lastVisible.offset.y + lastVisible.size.height < layoutInfo.viewportEndOffset + lastVisible.offset.y + lastVisible.size.height < layoutInfo.viewportEndOffset if (hasEmptySpace) { delay(100) @@ -506,16 +509,6 @@ fun SearchScreen( ) } - val visibleRepos by remember(state.repositories, state.isHideSeenEnabled, state.seenRepoIds) { - derivedStateOf { - if (state.isHideSeenEnabled && state.seenRepoIds.isNotEmpty()) { - state.repositories.filter { it.repository.id !in state.seenRepoIds } - } else { - state.repositories - } - } - } - Box(Modifier.fillMaxSize()) { if (state.isLoading && state.repositories.isEmpty()) { Box( @@ -548,75 +541,85 @@ fun SearchScreen( } } - if (visibleRepos.isNotEmpty()) { + if (state.visibleRepos.isNotEmpty()) { val isScrollbarEnabled = LocalScrollbarEnabled.current ScrollbarContainer( gridState = listState, enabled = isScrollbarEnabled, modifier = Modifier.fillMaxSize(), ) { - LazyVerticalStaggeredGrid( - state = listState, - columns = StaggeredGridCells.Adaptive(350.dp), - verticalItemSpacing = 12.dp, - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 12.dp), - modifier = - Modifier - .fillMaxSize() - .then( - if (state.isLiquidGlassEnabled) { - Modifier.liquefiable(liquidState) - } else { - Modifier + LazyVerticalStaggeredGrid( + state = listState, + columns = StaggeredGridCells.Adaptive(350.dp), + verticalItemSpacing = 12.dp, + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 12.dp), + modifier = + Modifier + .fillMaxSize() + .then( + if (state.isLiquidGlassEnabled) { + Modifier.liquefiable(liquidState) + } else { + Modifier + }, + ), + ) { + items( + items = state.visibleRepos, + key = { it.repository.id }, + ) { discoveryRepository -> + RepositoryCard( + discoveryRepositoryUi = discoveryRepository, + onClick = { + onAction(SearchAction.OnRepositoryClick(discoveryRepository.repository)) + }, + onDeveloperClick = { username -> + onAction(SearchAction.OnRepositoryDeveloperClick(username)) + }, + onShareClick = { + onAction(SearchAction.OnShareClick(discoveryRepository.repository)) }, - ), - ) { - items( - items = visibleRepos, - key = { it.repository.id }, - ) { discoveryRepository -> - RepositoryCard( - discoveryRepositoryUi = discoveryRepository, - onClick = { - onAction(SearchAction.OnRepositoryClick(discoveryRepository.repository)) - }, - onDeveloperClick = { username -> - onAction(SearchAction.OnRepositoryDeveloperClick(username)) - }, - onShareClick = { - onAction(SearchAction.OnShareClick(discoveryRepository.repository)) - }, - modifier = - Modifier - .animateItem() - .then( - if (state.isLiquidGlassEnabled) { - Modifier.liquefiable(liquidState) - } else { - Modifier - }, - ), - ) - } - - item { - if (state.isLoadingMore) { - Box( modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), + .animateItem() + .then( + if (state.isLiquidGlassEnabled) { + Modifier.liquefiable(liquidState) + } else { + Modifier + }, + ), + ) + } + + item { + if (state.isLoadingMore) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + ) + } + } + } + + // "Fetch more from GitHub" explore button + if (!state.isLoading && !state.isLoadingMore && state.query.isNotBlank()) { + item { + ExploreFromGithubButton( + status = state.exploreStatus, + onExplore = { onAction(SearchAction.ExploreFromGithub) }, ) } } } } - } // ScrollbarContainer } } } @@ -857,6 +860,52 @@ private fun SearchTopbar( } } +@Composable +private fun ExploreFromGithubButton( + status: SearchState.ExploreStatus, + onExplore: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + when (status) { + SearchState.ExploreStatus.IDLE -> { + OutlinedButton(onClick = onExplore) { + Icon( + imageVector = Icons.Outlined.TravelExplore, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(text = stringResource(Res.string.fetch_more_from_github)) + } + } + + SearchState.ExploreStatus.LOADING -> { + OutlinedButton(onClick = {}, enabled = false) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + Text(text = stringResource(Res.string.fetching_from_github)) + } + } + + SearchState.ExploreStatus.EXHAUSTED -> { + Text( + text = stringResource(Res.string.no_more_github_results), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + ) + } + } + } +} + @Preview @Composable private fun Preview() { diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt index c04f5890c..dc7d46092 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt @@ -12,6 +12,7 @@ import zed.rainxch.search.presentation.model.SortOrderUi data class SearchState( val query: String = "", val repositories: ImmutableList = persistentListOf(), + val visibleRepos: ImmutableList = persistentListOf(), val selectedSearchPlatform: SearchPlatformUi = SearchPlatformUi.All, val selectedSortBy: SortByUi = SortByUi.BestMatch, val selectedSortOrder: SortOrderUi = SortOrderUi.Descending, @@ -31,4 +32,11 @@ data class SearchState( val isClipboardBannerVisible: Boolean = false, val autoDetectClipboardEnabled: Boolean = true, val recentSearches: ImmutableList = persistentListOf(), -) + val exploreStatus: ExploreStatus = ExploreStatus.IDLE, +) { + enum class ExploreStatus { + IDLE, + LOADING, + EXHAUSTED, + } +} diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt index d79fabd40..7f02b2642 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt @@ -2,6 +2,7 @@ package zed.rainxch.search.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException @@ -10,6 +11,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn @@ -35,6 +37,7 @@ import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.failed_to_share_link import zed.rainxch.githubstore.core.presentation.res.link_copied_to_clipboard import zed.rainxch.githubstore.core.presentation.res.no_github_link_in_clipboard +import zed.rainxch.githubstore.core.presentation.res.explore_error import zed.rainxch.githubstore.core.presentation.res.no_repositories_found import zed.rainxch.githubstore.core.presentation.res.search_failed import zed.rainxch.search.presentation.mappers.toDomain @@ -58,6 +61,8 @@ class SearchViewModel( private var hasLoadedInitialData = false private var currentSearchJob: Job? = null private var currentPage = 1 + private var explorePage = 1 + private var lastExploreQuery = "" companion object { private const val MIN_QUERY_LENGTH = 3 @@ -82,12 +87,21 @@ class SearchViewModel( hasLoadedInitialData = true } - }.stateIn( + } + .map { it.copy(visibleRepos = computeVisibleRepos(it)) } + .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), initialValue = SearchState(), ) + private fun computeVisibleRepos(state: SearchState): ImmutableList = + if (state.isHideSeenEnabled && state.seenRepoIds.isNotEmpty()) { + state.repositories.filter { it.repository.id !in state.seenRepoIds }.toImmutableList() + } else { + state.repositories + } + private fun observeLiquidGlassEnabled() { viewModelScope.launch { tweaksRepository.getLiquidGlassEnabled().collect { enabled -> @@ -277,6 +291,9 @@ class SearchViewModel( if (isInitial) { currentSearchJob?.cancel() currentPage = 1 + explorePage = 1 + lastExploreQuery = query + _state.update { it.copy(exploreStatus = SearchState.ExploreStatus.IDLE) } } currentSearchJob = @@ -646,6 +663,82 @@ class SearchViewModel( searchHistoryRepository.clearAll() } } + + SearchAction.ExploreFromGithub -> { + performExplore() + } + } + } + + private fun performExplore() { + val query = _state.value.query.trim() + if (query.isBlank() || _state.value.exploreStatus == SearchState.ExploreStatus.LOADING) return + + // Reset page if query changed + if (query != lastExploreQuery) { + explorePage = 1 + lastExploreQuery = query + } + + viewModelScope.launch { + _state.update { it.copy(exploreStatus = SearchState.ExploreStatus.LOADING) } + + try { + val exploreResult = searchRepository.exploreFromGithub( + query = query, + platform = _state.value.selectedSearchPlatform.toDomain(), + page = explorePage, + ) + + if (exploreResult.repos.isEmpty() || !exploreResult.hasMore) { + if (exploreResult.repos.isNotEmpty()) { + appendExploreResults(exploreResult.repos) + } + _state.update { it.copy(exploreStatus = SearchState.ExploreStatus.EXHAUSTED) } + } else { + appendExploreResults(exploreResult.repos) + explorePage++ + _state.update { it.copy(exploreStatus = SearchState.ExploreStatus.IDLE) } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error("Explore failed: ${e.message}") + _state.update { it.copy(exploreStatus = SearchState.ExploreStatus.IDLE) } + _events.send(SearchEvent.OnMessage(getString(Res.string.explore_error))) + } + } + } + + private suspend fun appendExploreResults( + newRepos: List, + ) { + val installedMap = installedAppsRepository.getAllInstalledApps().first().associateBy { it.repoId } + val favoritesMap = favouritesRepository.getAllFavorites().first().associateBy { it.repoId } + val starredMap = starredRepository.getAllStarred().first().associateBy { it.repoId } + val seenIds = _state.value.seenRepoIds + + val existingIds = _state.value.repositories.map { it.repository.id }.toSet() + + val deduped = newRepos + .filter { it.id !in existingIds } + .map { repo -> + DiscoveryRepositoryUi( + isInstalled = installedMap[repo.id] != null, + isFavourite = favoritesMap[repo.id] != null, + isStarred = starredMap[repo.id] != null, + isSeen = repo.id in seenIds, + isUpdateAvailable = installedMap[repo.id]?.isUpdateAvailable ?: false, + repository = repo.toUi(), + ) + } + + if (deduped.isNotEmpty()) { + _state.update { current -> + current.copy( + repositories = (current.repositories + deduped).toImmutableList(), + ) + } } }