diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt index 85dd61e36..5d6dd4cab 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt @@ -96,10 +96,10 @@ class PackageEventReceiver() : } ?: return - Logger.d { "PackageEventReceiver: ${intent.action} for $packageName" } + Logger.d { "PackageEventReceiver: ${intent?.action} for $packageName" } try { - when (intent.action) { + when (intent?.action) { Intent.ACTION_PACKAGE_ADDED, Intent.ACTION_PACKAGE_REPLACED, Intent.ACTION_MY_PACKAGE_REPLACED, @@ -112,7 +112,7 @@ class PackageEventReceiver() : } } } catch (e: Exception) { - Logger.e { "PackageEventReceiver: Failed to handle ${intent.action}: ${e.message}" } + Logger.e { "PackageEventReceiver: Failed to handle ${intent?.action}: ${e.message}" } } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt index 1ff857229..cf9c121a0 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt @@ -26,10 +26,10 @@ interface InstalledAppDao { @Query("SELECT * FROM installed_apps WHERE repoId = :repoId") fun getAppByRepoIdAsFlow(repoId: Long): Flow - `@Query`("SELECT * FROM installed_apps WHERE repoId = :repoId ORDER BY installedAt DESC") + @Query("SELECT * FROM installed_apps WHERE repoId = :repoId ORDER BY installedAt DESC") suspend fun getAppsByRepoId(repoId: Long): List - `@Query`("SELECT * FROM installed_apps WHERE repoId = :repoId ORDER BY installedAt DESC") + @Query("SELECT * FROM installed_apps WHERE repoId = :repoId ORDER BY installedAt DESC") fun getAppsByRepoIdAsFlow(repoId: Long): Flow> @Query("SELECT COUNT(*) FROM installed_apps WHERE isUpdateAvailable = 1") diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt index 17f468720..a4291cc52 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt @@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import zed.rainxch.core.domain.model.AppLanguages @@ -148,15 +149,29 @@ class TweaksRepositoryImpl( } } - override fun getDiscoveryPlatform(): Flow = + override fun getDiscoveryPlatforms(): Flow> = preferences.data.map { prefs -> - val platform = prefs[DISCOVERY_PLATFORM_KEY] - DiscoveryPlatform.fromName(platform) + val stored = prefs[DISCOVERY_PLATFORMS_KEY] + if (stored != null) { + stored + .mapNotNull { name -> + DiscoveryPlatform.entries.find { it.name == name && it != DiscoveryPlatform.All } + }.toSet() + } else { + // Legacy single-platform key migration: map old `All` to empty set. + val legacy = prefs[DISCOVERY_PLATFORM_KEY]?.let { DiscoveryPlatform.fromName(it) } + if (legacy != null && legacy != DiscoveryPlatform.All) setOf(legacy) else emptySet() + } } - override suspend fun setDiscoveryPlatform(platform: DiscoveryPlatform) { + override suspend fun setDiscoveryPlatforms(platforms: Set) { preferences.edit { prefs -> - prefs[DISCOVERY_PLATFORM_KEY] = platform.name + val sanitized = + platforms + .filter { it != DiscoveryPlatform.All } + .map { it.name } + .toSet() + prefs[DISCOVERY_PLATFORMS_KEY] = sanitized } } @@ -272,6 +287,7 @@ class TweaksRepositoryImpl( private val IS_DARK_THEME_KEY = booleanPreferencesKey("is_dark_theme") private val FONT_KEY = stringPreferencesKey("font_theme") private val DISCOVERY_PLATFORM_KEY = stringPreferencesKey("discovery_platform") + private val DISCOVERY_PLATFORMS_KEY = stringSetPreferencesKey("discovery_platforms") private val AUTO_DETECT_CLIPBOARD_KEY = booleanPreferencesKey("auto_detect_clipboard_links") private val INSTALLER_TYPE_KEY = stringPreferencesKey("installer_type") private val AUTO_UPDATE_KEY = booleanPreferencesKey("auto_update_enabled") diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/DiscoveryPlatform.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/DiscoveryPlatform.kt index 0a6187a8d..6828e8c54 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/DiscoveryPlatform.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/DiscoveryPlatform.kt @@ -10,5 +10,8 @@ enum class DiscoveryPlatform { companion object { fun fromName(name: String?): DiscoveryPlatform = DiscoveryPlatform.entries.find { it.name == name } ?: All + + val selectablePlatforms: List = + listOf(Android, Macos, Windows, Linux) } } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt index cfad78dc5..9a2236f51 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt @@ -52,9 +52,9 @@ interface TweaksRepository { suspend fun setHideSeenEnabled(enabled: Boolean) - fun getDiscoveryPlatform(): Flow + fun getDiscoveryPlatforms(): Flow> - suspend fun setDiscoveryPlatform(platform: DiscoveryPlatform) + suspend fun setDiscoveryPlatforms(platforms: Set) fun getScrollbarEnabled(): Flow diff --git a/feature/home/.DS_Store b/feature/home/.DS_Store new file mode 100644 index 000000000..a7eac6c83 Binary files /dev/null and b/feature/home/.DS_Store differ diff --git a/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt b/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt index 194e101c6..fe55e0383 100644 --- a/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt +++ b/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt @@ -37,6 +37,7 @@ import zed.rainxch.core.domain.model.PaginatedDiscoveryRepositories import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.home.data.data_source.CachedRepositoriesDataSource +import zed.rainxch.home.data.dto.CachedRepoResponse import zed.rainxch.home.data.mappers.toGithubRepoSummary import zed.rainxch.home.domain.repository.HomeRepository import kotlin.time.Clock @@ -54,20 +55,54 @@ class HomeRepositoryImpl( private fun cacheKey( category: String, - requestedPlatform: DiscoveryPlatform, + requestedPlatforms: Set, page: Int, - ): String = "home:$category:${requestedPlatform.name}:page$page" + ): String { + val effective = requestedPlatforms.normalize() + val token = + if (effective.isEmpty()) { + "all" + } else { + effective.map { it.name }.sorted().joinToString(separator = "+") + } + return "home:$category:$token:page$page" + } + + // Mirror cache only ships per-platform snapshots and a merged "all" file. + // For 2-3 platform selections we pull the merged set and filter by + // intersection on the client side rather than fetching N snapshots. + private suspend fun loadCachedReposForSet( + platforms: Set, + fetchSingle: suspend (DiscoveryPlatform) -> CachedRepoResponse?, + ): CachedRepoResponse? { + val effective = platforms.normalize() + return when (effective.size) { + 0 -> fetchSingle(DiscoveryPlatform.All) + 1 -> fetchSingle(effective.first()) + else -> + fetchSingle(DiscoveryPlatform.All)?.let { response -> + response.copy( + repositories = + response.repositories.filter { repo -> + repo.availablePlatforms.any { it in effective } + }, + ) + } + } + } @OptIn(ExperimentalTime::class) override fun getTrendingRepositories( - platform: DiscoveryPlatform, + platforms: Set, page: Int, ): Flow = flow { if (page == 1) { logger.debug("Attempting to load cached trending repositories...") - val cachedData = cachedDataSource.getCachedTrendingRepos(platform) + val cachedData = loadCachedReposForSet(platforms) { + cachedDataSource.getCachedTrendingRepos(it) + } if (cachedData != null && cachedData.repositories.isNotEmpty()) { logger.debug("Using mirror cached data: ${cachedData.repositories.size} repos") @@ -84,7 +119,7 @@ class HomeRepositoryImpl( key = cacheKey( category = "trending", - requestedPlatform = platform, + requestedPlatforms = platforms, page = page, ), value = result, @@ -101,7 +136,7 @@ class HomeRepositoryImpl( cacheManager.get( cacheKey( category = "trending", - requestedPlatform = platform, + requestedPlatforms = platforms, page = page, ), ) @@ -120,7 +155,7 @@ class HomeRepositoryImpl( emitAll( searchReposWithInstallersFlow( - platform = platform, + platforms = platforms, baseQuery = "stars:>50 archived:false pushed:>=$thirtyDaysAgo", sort = "stars", order = "desc", @@ -132,14 +167,16 @@ class HomeRepositoryImpl( @OptIn(ExperimentalTime::class) override fun getHotReleaseRepositories( - platform: DiscoveryPlatform, + platforms: Set, page: Int, ): Flow = flow { if (page == 1) { logger.debug("Attempting to load cached hot release repositories...") - val cachedData = cachedDataSource.getCachedHotReleaseRepos(platform) + val cachedData = loadCachedReposForSet(platforms) { + cachedDataSource.getCachedHotReleaseRepos(it) + } if (cachedData != null && cachedData.repositories.isNotEmpty()) { logger.debug("Using mirror cached data: ${cachedData.repositories.size} repos") @@ -156,7 +193,7 @@ class HomeRepositoryImpl( key = cacheKey( category = "hot_release", - requestedPlatform = platform, + requestedPlatforms = platforms, page = page, ), value = result, @@ -173,7 +210,7 @@ class HomeRepositoryImpl( cacheManager.get( cacheKey( category = "hot_release", - requestedPlatform = platform, + requestedPlatforms = platforms, page = page, ), ) @@ -192,7 +229,7 @@ class HomeRepositoryImpl( emitAll( searchReposWithInstallersFlow( - platform = platform, + platforms = platforms, baseQuery = "stars:>10 archived:false pushed:>=$fourteenDaysAgo", sort = "updated", order = "desc", @@ -204,14 +241,16 @@ class HomeRepositoryImpl( @OptIn(ExperimentalTime::class) override fun getMostPopular( - platform: DiscoveryPlatform, + platforms: Set, page: Int, ): Flow = flow { if (page == 1) { logger.debug("Attempting to load cached most popular repositories...") - val cachedData = cachedDataSource.getCachedMostPopularRepos(platform) + val cachedData = loadCachedReposForSet(platforms) { + cachedDataSource.getCachedMostPopularRepos(it) + } if (cachedData != null && cachedData.repositories.isNotEmpty()) { logger.debug("Using mirror cached data: ${cachedData.repositories.size} repos") @@ -224,7 +263,7 @@ class HomeRepositoryImpl( hasMore = false, nextPageIndex = 2, ) - cacheManager.put(cacheKey("most_popular", platform, page), result, HOME_REPOS) + cacheManager.put(cacheKey("most_popular", platforms, page), result, HOME_REPOS) emit(result) return@flow } else { @@ -236,7 +275,7 @@ class HomeRepositoryImpl( cacheManager.get( cacheKey( category = "most_popular", - requestedPlatform = platform, + requestedPlatforms = platforms, page = page, ), ) @@ -262,7 +301,7 @@ class HomeRepositoryImpl( emitAll( searchReposWithInstallersFlow( - platform = platform, + platforms = platforms, baseQuery = "stars:>1000 archived:false created:<$sixMonthsAgo pushed:>=$oneYearAgo", sort = "stars", order = "desc", @@ -274,10 +313,12 @@ class HomeRepositoryImpl( override fun getTopicRepositories( topic: zed.rainxch.home.domain.model.TopicCategory, - platform: DiscoveryPlatform, + platforms: Set, ): Flow = flow { - val cachedData = cachedDataSource.getCachedTopicRepos(topic, platform) + val cachedData = loadCachedReposForSet(platforms) { + cachedDataSource.getCachedTopicRepos(topic, it) + } if (cachedData != null && cachedData.repositories.isNotEmpty()) { logger.debug("Using cached topic data for ${topic.name}: ${cachedData.repositories.size} repos") @@ -295,7 +336,7 @@ class HomeRepositoryImpl( override fun searchByTopic( searchKeywords: String, - platform: DiscoveryPlatform, + platforms: Set, page: Int, ): Flow = flow { @@ -304,7 +345,7 @@ class HomeRepositoryImpl( cacheManager.get( cacheKey( category = cacheCategory, - requestedPlatform = platform, + requestedPlatforms = platforms, page = page, ), ) @@ -316,7 +357,7 @@ class HomeRepositoryImpl( emitAll( searchReposWithInstallersFlow( - platform = platform, + platforms = platforms, baseQuery = "$searchKeywords in:name,description,topics stars:>10 archived:false", sort = "stars", order = "desc", @@ -327,7 +368,7 @@ class HomeRepositoryImpl( }.flowOn(Dispatchers.IO) private fun searchReposWithInstallersFlow( - platform: DiscoveryPlatform, + platforms: Set, baseQuery: String, sort: String, order: String, @@ -344,7 +385,7 @@ class HomeRepositoryImpl( var pagesFetchedCount = 0 var lastEmittedCount = 0 - val query = buildSimplifiedQuery(baseQuery, platform) + val query = buildSimplifiedQuery(baseQuery, platforms) logger.debug("Query: $query | Sort: $sort | Page: $startPage") while (results.size < desiredCount && pagesFetchedCount < maxPagesToFetch) { @@ -479,7 +520,7 @@ class HomeRepositoryImpl( key = cacheKey( category = category, - requestedPlatform = platform, + requestedPlatforms = platforms, page = startPage, ), value = allResults, @@ -491,18 +532,39 @@ class HomeRepositoryImpl( private fun buildSimplifiedQuery( baseQuery: String, - requestedPlatform: DiscoveryPlatform, + requestedPlatforms: Set, ): String { - val topic = - when (requestedPlatform) { - DiscoveryPlatform.All -> null - DiscoveryPlatform.Android -> "android" - DiscoveryPlatform.Windows -> "desktop" - DiscoveryPlatform.Macos -> "macos" - DiscoveryPlatform.Linux -> "linux" - } + val effective = requestedPlatforms.normalize() + if (effective.isEmpty()) return baseQuery + + val topics = + effective + .mapNotNull { it.toQueryTopic() } + .distinct() + + return when { + topics.isEmpty() -> baseQuery + topics.size == 1 -> "$baseQuery topic:${topics.first()}" + // Classic REST search doesn't support parenthesized qualifier grouping. + // Repeat the full base query per topic joined with OR. + else -> topics.joinToString(separator = " OR ") { "($baseQuery topic:$it)" } + } + } + + private fun DiscoveryPlatform.toQueryTopic(): String? = + when (this) { + DiscoveryPlatform.All -> null + DiscoveryPlatform.Android -> "android" + DiscoveryPlatform.Windows -> "desktop" + DiscoveryPlatform.Macos -> "macos" + DiscoveryPlatform.Linux -> "linux" + } - return if (topic == null) baseQuery else "$baseQuery topic:$topic" + /** Treat `All` and a fully-covering set as "no filter" (empty set). */ + private fun Set.normalize(): Set { + if (contains(DiscoveryPlatform.All)) return emptySet() + val real = filter { it != DiscoveryPlatform.All }.toSet() + return if (real.size == DiscoveryPlatform.selectablePlatforms.size) emptySet() else real } private fun calculatePlatformScore(repo: GithubRepoNetworkModel): Int { diff --git a/feature/home/domain/src/commonMain/kotlin/zed/rainxch/home/domain/repository/HomeRepository.kt b/feature/home/domain/src/commonMain/kotlin/zed/rainxch/home/domain/repository/HomeRepository.kt index 4c9a90b33..a9cd01f1b 100644 --- a/feature/home/domain/src/commonMain/kotlin/zed/rainxch/home/domain/repository/HomeRepository.kt +++ b/feature/home/domain/src/commonMain/kotlin/zed/rainxch/home/domain/repository/HomeRepository.kt @@ -7,28 +7,28 @@ import zed.rainxch.home.domain.model.TopicCategory interface HomeRepository { fun getTrendingRepositories( - platform: DiscoveryPlatform, + platforms: Set, page: Int, ): Flow fun getHotReleaseRepositories( - platform: DiscoveryPlatform, + platforms: Set, page: Int, ): Flow fun getMostPopular( - platform: DiscoveryPlatform, + platforms: Set, page: Int, ): Flow fun searchByTopic( searchKeywords: String, - platform: DiscoveryPlatform, + platforms: Set, page: Int, ): Flow fun getTopicRepositories( topic: TopicCategory, - platform: DiscoveryPlatform, + platforms: Set, ): Flow } diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeAction.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeAction.kt index fc480a2b0..3440b1976 100644 --- a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeAction.kt +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeAction.kt @@ -20,6 +20,8 @@ sealed interface HomeAction { data object OnTogglePlatformPopup : HomeAction + data object OnSelectAllPlatforms : HomeAction + data class OnShareClick( val repo: GithubRepoSummaryUi, ) : HomeAction @@ -29,10 +31,10 @@ sealed interface HomeAction { ) : HomeAction data class SwitchTopic( - val topic: TopicCategory?, + val topic: TopicCategory, ) : HomeAction - data class SwitchDiscoveryPlatform( + data class TogglePlatform( val platform: DiscoveryPlatform, ) : HomeAction diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt index 39a747b3f..db018a63d 100644 --- a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt @@ -1,10 +1,7 @@ package zed.rainxch.home.presentation -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -65,12 +62,18 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.fletchmckee.liquid.LiquidState import io.github.fletchmckee.liquid.liquefiable @@ -233,11 +236,7 @@ fun HomeScreen( ) { CollapsibleHeader(scrollBehavior = scrollBehavior) { HomeTopAppBar( - currentPlatform = state.currentPlatform, - onChangePlatform = { - onAction(HomeAction.SwitchDiscoveryPlatform(it)) - }, - isPlatformPopupVisible = state.isPlatformPopupVisible, + selectedPlatforms = state.selectedPlatforms, onTogglePlatformPopup = { onAction(HomeAction.OnTogglePlatformPopup) }, @@ -246,7 +245,7 @@ fun HomeScreen( FilterChips(state, onAction) TopicChips( - selectedTopic = state.selectedTopic, + selectedTopics = state.selectedTopics, onTopicSelected = { topic -> onAction(HomeAction.SwitchTopic(topic)) }, @@ -269,6 +268,26 @@ fun HomeScreen( ) } } + + // Popup is hoisted out of the TopAppBar so its parent's + // `LayoutCoordinates` are the (stable) Scaffold root rather + // than the (resizing) icons row. Without this, every icon + // count change during multi-select toggles the parent layout + // pass and the Popup window briefly tears down and re-creates. + if (state.isPlatformPopupVisible) { + PlatformsPopup( + onTogglePlatformPopup = { + onAction(HomeAction.OnTogglePlatformPopup) + }, + onTogglePlatform = { + onAction(HomeAction.TogglePlatform(it)) + }, + onSelectAllPlatforms = { + onAction(HomeAction.OnSelectAllPlatforms) + }, + selectedPlatforms = state.selectedPlatforms, + ) + } } } } @@ -320,7 +339,7 @@ private fun CollapsibleHeader( @Composable private fun TopicChips( - selectedTopic: TopicCategory?, + selectedTopics: Set, onTopicSelected: (TopicCategory) -> Unit, ) { Row( @@ -332,7 +351,7 @@ private fun TopicChips( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { TopicCategory.entries.forEach { topic -> - val isSelected = selectedTopic == topic + val isSelected = topic in selectedTopics val containerColor by animateColorAsState( targetValue = @@ -588,9 +607,7 @@ private fun FilterChips( @Composable @OptIn(ExperimentalMaterial3Api::class) private fun HomeTopAppBar( - currentPlatform: DiscoveryPlatform, - onChangePlatform: (DiscoveryPlatform) -> Unit, - isPlatformPopupVisible: Boolean, + selectedPlatforms: Set, onTogglePlatformPopup: () -> Unit, ) { TopAppBar( @@ -620,7 +637,7 @@ private fun HomeTopAppBar( ) }, actions = { - val icons = currentPlatform.toIcons() + val icons = selectedPlatformsIcons(selectedPlatforms) Row( modifier = @@ -641,20 +658,6 @@ private fun HomeTopAppBar( ) } } - - AnimatedVisibility( - visible = isPlatformPopupVisible, - enter = slideInVertically(), - exit = slideOutVertically(), - ) { - Box { - PlatformsPopup( - onTogglePlatformPopup = onTogglePlatformPopup, - onChangePlatform = onChangePlatform, - currentPlatform = currentPlatform, - ) - } - } }, modifier = Modifier.padding(12.dp), contentPadding = PaddingValues(), @@ -662,14 +665,34 @@ private fun HomeTopAppBar( ) } +@Composable +private fun selectedPlatformsIcons( + selectedPlatforms: Set, +) = if (selectedPlatforms.isEmpty()) { + DiscoveryPlatform.All.toIcons() +} else { + DiscoveryPlatform.selectablePlatforms + .filter { it in selectedPlatforms } + .flatMap { it.toIcons() } +} + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun PlatformsPopup( onTogglePlatformPopup: () -> Unit, - onChangePlatform: (DiscoveryPlatform) -> Unit, - currentPlatform: DiscoveryPlatform, + onTogglePlatform: (DiscoveryPlatform) -> Unit, + onSelectAllPlatforms: () -> Unit, + selectedPlatforms: Set, ) { + val density = LocalDensity.current + val positionProvider = remember(density) { + WindowAnchoredTopEndPopupPositionProvider( + topPaddingPx = with(density) { 80.dp.roundToPx() }, + endPaddingPx = with(density) { 16.dp.roundToPx() }, + ) + } Popup( + popupPositionProvider = positionProvider, onDismissRequest = onTogglePlatformPopup, ) { Column( @@ -679,46 +702,87 @@ private fun PlatformsPopup( .clip(RoundedCornerShape(24.dp)) .background(MaterialTheme.colorScheme.surfaceContainerHighest), ) { - DiscoveryPlatform.entries.forEach { platform -> - Box( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = { - onChangePlatform(platform) - onTogglePlatformPopup() - }) - .padding(horizontal = 24.dp, vertical = 8.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = - Arrangement.spacedBy( - 6.dp, - Alignment.Start, - ), - ) { - if (currentPlatform == platform) { - Icon( - imageVector = Icons.Default.Done, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - } + PlatformPopupRow( + label = DiscoveryPlatform.All.toLabel(), + isSelected = selectedPlatforms.isEmpty(), + onClick = onSelectAllPlatforms, + ) - Text( - text = platform.toLabel(), - style = MaterialTheme.typography.titleMediumEmphasized, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } + DiscoveryPlatform.selectablePlatforms.forEach { platform -> + PlatformPopupRow( + label = platform.toLabel(), + isSelected = platform in selectedPlatforms, + onClick = { onTogglePlatform(platform) }, + ) } } } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun PlatformPopupRow( + label: String, + isSelected: Boolean, + onClick: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 24.dp, vertical = 8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = + Arrangement.spacedBy( + 6.dp, + Alignment.Start, + ), + ) { + if (isSelected) { + Icon( + imageVector = Icons.Default.Done, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + + Text( + text = label, + style = MaterialTheme.typography.titleMediumEmphasized, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +// Pins the popup to the window's top-end corner instead of anchoring to its +// parent's `LayoutCoordinates`. The default Compose anchor jitters when the +// parent (here, the platform-icons Row inside the TopAppBar action slot) +// resizes during multi-select toggles, causing the popup to flicker. +private class WindowAnchoredTopEndPopupPositionProvider( + private val topPaddingPx: Int, + private val endPaddingPx: Int, +) : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val x = + if (layoutDirection == LayoutDirection.Ltr) { + windowSize.width - popupContentSize.width - endPaddingPx + } else { + endPaddingPx + } + return IntOffset(x.coerceAtLeast(0), topPaddingPx) + } +} + @Preview @Composable private fun Preview() { diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeState.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeState.kt index 64040f190..9447f9454 100644 --- a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeState.kt +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeState.kt @@ -17,10 +17,15 @@ data class HomeState( val errorMessage: String? = null, val hasMorePages: Boolean = true, val currentCategory: HomeCategory = HomeCategory.TRENDING, - val selectedTopic: TopicCategory? = null, + /** Empty set means "no topic filter" (show all topics). */ + val selectedTopics: Set = emptySet(), val isAppsSectionVisible: Boolean = false, val isUpdateAvailable: Boolean = false, - val currentPlatform: DiscoveryPlatform = DiscoveryPlatform.All, + /** + * Empty set means "all platforms" (no filter). Anything else is the + * subset the user explicitly opted into via the platform popup. + */ + val selectedPlatforms: Set = emptySet(), val isPlatformPopupVisible: Boolean = false, val isLiquidGlassEnabled: Boolean = true, val isHideSeenEnabled: Boolean = false, diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeViewModel.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeViewModel.kt index 6b2273970..9c619f552 100644 --- a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeViewModel.kt +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeViewModel.kt @@ -7,10 +7,13 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn @@ -69,7 +72,7 @@ class HomeViewModel( observeStarredRepos() observeLiquidGlassEnabled() observeSeenRepos() - observeDiscoveryPlatform() + observeDiscoveryPlatforms() observeHideSeenEnabled() hasLoadedInitialData = true @@ -124,12 +127,12 @@ class HomeViewModel( } } - private fun observeDiscoveryPlatform() { + private fun observeDiscoveryPlatforms() { viewModelScope.launch { - tweaksRepository.getDiscoveryPlatform().collect { platform -> + tweaksRepository.getDiscoveryPlatforms().collect { platforms -> _state.update { it.copy( - currentPlatform = platform, + selectedPlatforms = platforms, ) } } @@ -139,9 +142,9 @@ class HomeViewModel( private fun loadRepos( isInitial: Boolean = false, category: HomeCategory? = null, - platform: DiscoveryPlatform? = null, - topic: TopicCategory? = null, - topicExplicitlySet: Boolean = false, + platforms: Set? = null, + topics: Set? = null, + topicsExplicitlySet: Boolean = false, ): Job? { currentJob?.cancel() topicSupplementJob?.cancel() @@ -156,20 +159,20 @@ class HomeViewModel( } val targetCategory = category ?: _state.value.currentCategory - val targetPlatformDeffered = + val targetPlatformsDeferred = viewModelScope.async { - tweaksRepository.getDiscoveryPlatform().first() + tweaksRepository.getDiscoveryPlatforms().first() } - val targetTopic = if (topicExplicitlySet) topic else _state.value.selectedTopic + val targetTopics = if (topicsExplicitlySet) topics.orEmpty() else _state.value.selectedTopics - logger.debug("Loading repos: category=$targetCategory, topic=$targetTopic, page=$nextPageIndex, isInitial=$isInitial") + logger.debug("Loading repos: category=$targetCategory, topics=$targetTopics, page=$nextPageIndex, isInitial=$isInitial") return viewModelScope .launch { - val targetPlatform = platform ?: targetPlatformDeffered.await() + val targetPlatforms = platforms ?: targetPlatformsDeferred.await() - if (platform != null) { - tweaksRepository.setDiscoveryPlatform(targetPlatform) + if (platforms != null) { + tweaksRepository.setDiscoveryPlatforms(targetPlatforms) } _state.update { @@ -177,9 +180,9 @@ class HomeViewModel( isLoading = isInitial, isLoadingMore = !isInitial, errorMessage = null, - currentPlatform = targetPlatform, + selectedPlatforms = targetPlatforms, currentCategory = targetCategory, - selectedTopic = targetTopic, + selectedTopics = targetTopics, repos = if (isInitial) persistentListOf() else it.repos, ) } @@ -189,21 +192,21 @@ class HomeViewModel( when (targetCategory) { HomeCategory.TRENDING -> { homeRepository.getTrendingRepositories( - platform = targetPlatform, + platforms = targetPlatforms, page = nextPageIndex, ) } HomeCategory.HOT_RELEASE -> { homeRepository.getHotReleaseRepositories( - platform = targetPlatform, + platforms = targetPlatforms, page = nextPageIndex, ) } HomeCategory.MOST_POPULAR -> { homeRepository.getMostPopular( - platform = targetPlatform, + platforms = targetPlatforms, page = nextPageIndex, ) } @@ -217,12 +220,14 @@ class HomeViewModel( this@HomeViewModel.nextPageIndex = paginatedRepos.nextPageIndex val repos = - if (targetTopic != null) { + if (targetTopics.isEmpty()) { + paginatedRepos.repos + } else { paginatedRepos.repos.filter { repo -> - targetTopic.matchesRepo(repo.topics, repo.description, repo.name) + targetTopics.any { topic -> + topic.matchesRepo(repo.topics, repo.description, repo.name) + } } - } else { - paginatedRepos.repos } val newReposWithStatus = mapReposToUi(repos) @@ -249,8 +254,8 @@ class HomeViewModel( it.copy(isLoading = false, isLoadingMore = false) } - if (targetTopic != null && isInitial) { - loadTopicSupplement(targetTopic, targetPlatform) + if (targetTopics.isNotEmpty() && isInitial) { + loadTopicSupplement(targetTopics, targetPlatforms) } } catch (t: Throwable) { if (t is CancellationException) { @@ -275,8 +280,8 @@ class HomeViewModel( } private fun loadTopicSupplement( - topic: TopicCategory, - platform: DiscoveryPlatform, + topics: Set, + platforms: Set, ) { topicSupplementJob?.cancel() topicSupplementJob = @@ -284,49 +289,55 @@ class HomeViewModel( _state.update { it.copy(isLoadingTopicSupplement = true) } try { - // Phase 1: Load pre-fetched cached topic repos (instant, no API cost) - homeRepository - .getTopicRepositories( - topic = topic, - platform = platform, - ).collect { paginatedRepos -> - if (paginatedRepos.repos.isNotEmpty()) { - val cachedReposWithStatus = mapReposToUi(paginatedRepos.repos) + // Phase 1: Pre-fetched cached topic repos (instant, no API cost). + // Run mirror fetches per selected topic in parallel, merge once. + val cachedRepos = + coroutineScope { + topics.map { topic -> + async { + homeRepository + .getTopicRepositories(topic = topic, platforms = platforms) + .firstOrNull() + ?.repos + .orEmpty() + } + }.awaitAll().flatten() + } + if (cachedRepos.isNotEmpty()) { + val cachedReposWithStatus = mapReposToUi(cachedRepos) + _state.update { currentState -> + val merged = + (currentState.repos + cachedReposWithStatus) + .distinctBy { it.repository.fullName } + currentState.copy(repos = merged.toImmutableList()) + } + logger.debug("Loaded ${cachedRepos.size} cached topic repos for $topics") + } + + // Phase 2: Live GitHub search (fills gaps). One search per selected + // topic so each topic's keywords AND together correctly inside its + // own query, while results from different topics OR via merge. + topics.forEach { topic -> + homeRepository + .searchByTopic( + searchKeywords = topic.searchKeywords, + platforms = platforms, + page = 1, + ).collect { paginatedRepos -> + val newReposWithStatus = mapReposToUi(paginatedRepos.repos) _state.update { currentState -> val merged = - (currentState.repos + cachedReposWithStatus) + (currentState.repos + newReposWithStatus) .distinctBy { it.repository.fullName } currentState.copy( repos = merged.toImmutableList(), + hasMorePages = currentState.hasMorePages || paginatedRepos.hasMore, ) } - - logger.debug("Loaded ${paginatedRepos.repos.size} cached topic repos for ${topic.name}") - } - } - - // Phase 2: Supplement with live GitHub search (fills gaps) - homeRepository - .searchByTopic( - searchKeywords = topic.searchKeywords, - platform = platform, - page = 1, - ).collect { paginatedRepos -> - val newReposWithStatus = mapReposToUi(paginatedRepos.repos) - - _state.update { currentState -> - val merged = - (currentState.repos + newReposWithStatus) - .distinctBy { it.repository.fullName } - - currentState.copy( - repos = merged.toImmutableList(), - hasMorePages = currentState.hasMorePages || paginatedRepos.hasMore, - ) } - } + } } catch (t: Throwable) { if (t is CancellationException) throw t logger.warn("Topic supplement search failed: ${t.message}") @@ -399,16 +410,18 @@ class HomeViewModel( } is HomeAction.SwitchTopic -> { - val newTopic = if (_state.value.selectedTopic == action.topic) null else action.topic - if (_state.value.selectedTopic != newTopic) { + val current = _state.value.selectedTopics + val target = + if (action.topic in current) current - action.topic else current + action.topic + if (target != current) { nextPageIndex = 1 switchCategoryJob?.cancel() switchCategoryJob = viewModelScope.launch { loadRepos( isInitial = true, - topic = newTopic, - topicExplicitlySet = true, + topics = target, + topicsExplicitlySet = true, )?.join() ?: return@launch _events.send(HomeEvent.OnScrollToListTop) } @@ -446,13 +459,34 @@ class HomeViewModel( } } - is HomeAction.SwitchDiscoveryPlatform -> { - if (_state.value.currentPlatform != action.platform) { + is HomeAction.TogglePlatform -> { + val current = _state.value.selectedPlatforms + val target = current.toggle(action.platform) + if (target != current) { nextPageIndex = 1 switchCategoryJob?.cancel() switchCategoryJob = viewModelScope.launch { - loadRepos(isInitial = true, platform = action.platform)?.join() + loadRepos(isInitial = true, platforms = target)?.join() + ?: return@launch + _events.send(OnScrollToListTop) + } + } + } + + HomeAction.OnSelectAllPlatforms -> { + val target = + if (_state.value.selectedPlatforms.isEmpty()) { + setOf(devicePlatformAsDiscovery()) + } else { + emptySet() + } + if (target != _state.value.selectedPlatforms) { + nextPageIndex = 1 + switchCategoryJob?.cancel() + switchCategoryJob = + viewModelScope.launch { + loadRepos(isInitial = true, platforms = target)?.join() ?: return@launch _events.send(OnScrollToListTop) } @@ -489,6 +523,37 @@ class HomeViewModel( } } + /** + * Tap-from-`All` (empty selection) selects only the tapped platform — + * not "every other platform" — which is what users intuit from the + * popup. Tapping the only remaining platform deselects it and falls + * back to the device's own platform so the home feed never ends up + * empty. Reaching every selectable platform collapses to the `All` + * representation (empty set) to keep the chip row tidy. + */ + private fun Set.toggle(platform: DiscoveryPlatform): Set { + if (platform == DiscoveryPlatform.All) return emptySet() + + if (isEmpty()) return setOf(platform) + + val mutated = + if (platform in this) this - platform else this + platform + + return when { + mutated.size == DiscoveryPlatform.selectablePlatforms.size -> emptySet() + mutated.isEmpty() -> setOf(devicePlatformAsDiscovery()) + else -> mutated + } + } + + private fun devicePlatformAsDiscovery(): DiscoveryPlatform = + when (platform) { + Platform.ANDROID -> DiscoveryPlatform.Android + Platform.WINDOWS -> DiscoveryPlatform.Windows + Platform.MACOS -> DiscoveryPlatform.Macos + Platform.LINUX -> DiscoveryPlatform.Linux + } + private fun observeLiquidGlassEnabled() { viewModelScope.launch { tweaksRepository.getLiquidGlassEnabled().collect { enabled ->