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 6331b2d9c..706fbaf1b 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 @@ -352,6 +352,17 @@ class TweaksRepositoryImpl( } } + override fun getShowAllPlatforms(): Flow = + preferences.data.map { prefs -> + prefs[SHOW_ALL_PLATFORMS_KEY] ?: false + } + + override suspend fun setShowAllPlatforms(enabled: Boolean) { + preferences.edit { prefs -> + prefs[SHOW_ALL_PLATFORMS_KEY] = enabled + } + } + override fun getBatteryOptimizationPromptDismissed(): Flow = preferences.data.map { prefs -> prefs[BATTERY_OPT_PROMPT_DISMISSED_KEY] ?: false @@ -457,6 +468,7 @@ class TweaksRepositoryImpl( private val EXTERNAL_IMPORT_BANNER_DISMISSED_AT_KEY = intPreferencesKey("external_import_banner_dismissed_at") private val APK_INSPECT_COACHMARK_SHOWN_KEY = booleanPreferencesKey("apk_inspect_coachmark_shown") private val CHANNEL_CHIP_COACHMARK_SHOWN_KEY = booleanPreferencesKey("channel_chip_coachmark_shown") + private val SHOW_ALL_PLATFORMS_KEY = booleanPreferencesKey("show_all_platforms") private val BATTERY_OPT_PROMPT_DISMISSED_KEY = booleanPreferencesKey("battery_opt_prompt_dismissed") private val LAST_SEEN_WHATS_NEW_VERSION_CODE_KEY = intPreferencesKey("last_seen_whats_new_version_code") private val ANNOUNCEMENTS_DISMISSED_IDS_KEY = stringSetPreferencesKey("announcements_dismissed_ids") 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 2051e6075..c8755e286 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 @@ -123,6 +123,15 @@ interface TweaksRepository { suspend fun setChannelChipCoachmarkShown(shown: Boolean) + /** + * When true, the release-assets picker on Details shows installers + * for every OS (grouped by platform section). When false (default), + * only assets installable on the current platform are listed. + */ + fun getShowAllPlatforms(): Flow + + suspend fun setShowAllPlatforms(enabled: Boolean) + /** * One-shot watermark for the battery-optimization prompt on * aggressive-OEM ROMs (Oppo / OnePlus / Realme / Xiaomi / vivo / diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetPlatform.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetPlatform.kt new file mode 100644 index 000000000..7c766d1a0 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetPlatform.kt @@ -0,0 +1,26 @@ +package zed.rainxch.core.domain.util + +import zed.rainxch.core.domain.model.DiscoveryPlatform + +/** + * Maps a release asset filename to the OS platform it targets, by + * extension. Returns `null` for files we can't classify (zip bundles, + * sources, sig/sha files, etc.) — callers should drop those from the + * cross-platform picker so the list isn't polluted by non-installable + * sidecars. + */ +fun assetPlatformOf(assetName: String): DiscoveryPlatform? { + val lower = assetName.lowercase() + return when { + lower.endsWith(".apk") -> DiscoveryPlatform.Android + lower.endsWith(".exe") || lower.endsWith(".msi") -> DiscoveryPlatform.Windows + lower.endsWith(".dmg") || lower.endsWith(".pkg") -> DiscoveryPlatform.Macos + lower.endsWith(".deb") || + lower.endsWith(".rpm") || + lower.endsWith(".appimage") || + lower.endsWith(".pkg.tar.zst") || + lower.endsWith(".snap") || + lower.endsWith(".flatpakref") -> DiscoveryPlatform.Linux + else -> null + } +} diff --git a/core/presentation/src/commonMain/composeResources/files/whatsnew/17.json b/core/presentation/src/commonMain/composeResources/files/whatsnew/17.json index 24d54daf0..8c41fd508 100644 --- a/core/presentation/src/commonMain/composeResources/files/whatsnew/17.json +++ b/core/presentation/src/commonMain/composeResources/files/whatsnew/17.json @@ -12,7 +12,8 @@ "Silent install via root — Magisk, KernelSU, and APatch users can now install without Shizuku or Dhizuku.", "Search in Starred and Favourites — quickly filter long lists by repo name, owner, description, or language.", "Self-owned ✓ badge — when you're signed in, repos you own get a verified checkmark on Home and Search cards.", - "Hide repository — long-press any repo card on Home or Search to hide it from discovery. The repo stays in your library if you have it installed." + "Hide repository — long-press any repo card on Home or Search to hide it from discovery. The repo stays in your library if you have it installed.", + "Multi-OS release picker — toggle 'Show all platforms' on the asset picker to grab Android APKs from desktop or Linux .debs from your phone. Other-platform downloads open in your browser to save for transfer." ] }, { 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 55b12c82d..0782f19bf 100644 --- a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml +++ b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml @@ -1165,4 +1165,12 @@ فتح في GitHub وضع علامة مشاهد إلغاء علامة مشاهد + إظهار جميع المنصات + Android + Windows + macOS + Linux + منصة أخرى — يفتح في المتصفح للحفظ والنقل + جهازك + للنقل 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 9a7bfc34b..328decaed 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -1141,4 +1141,12 @@ GitHub-এ খুলুন দেখা হিসেবে চিহ্নিত করুন অদেখা হিসেবে চিহ্নিত করুন + সব প্ল্যাটফর্ম দেখান + Android + Windows + macOS + Linux + অন্য প্ল্যাটফর্ম — ট্রান্সফারের জন্য সংরক্ষণে ব্রাউজারে খুলবে + আপনার ডিভাইস + ট্রান্সফারের জন্য 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 43cfd3fdb..9a2323f6f 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -1113,4 +1113,12 @@ Abrir en GitHub Marcar como visto Marcar como no visto + Mostrar todas las plataformas + Android + Windows + macOS + Linux + Otra plataforma — se abre en el navegador para guardar y transferir + Tu dispositivo + Para transferir 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 7d41f1c31..0a593d247 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -1114,4 +1114,12 @@ Ouvrir sur GitHub Marquer comme vu Marquer comme non vu + Afficher toutes les plateformes + Android + Windows + macOS + Linux + Autre plateforme — s\'ouvre dans le navigateur pour enregistrement et transfert + Votre appareil + Pour transfert 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 b84d84048..47ce9bf06 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -1152,4 +1152,12 @@ GitHub पर खोलें देखा हुआ चिह्नित करें अनदेखा चिह्नित करें + सभी प्लेटफ़ॉर्म दिखाएं + Android + Windows + macOS + Linux + अन्य प्लेटफ़ॉर्म — ट्रांसफ़र के लिए सहेजने हेतु ब्राउज़र में खुलता है + आपका डिवाइस + ट्रांसफ़र के लिए 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 013cc6899..8e779f379 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -1153,4 +1153,12 @@ Apri su GitHub Segna come visto Segna come non visto + Mostra tutte le piattaforme + Android + Windows + macOS + Linux + Altra piattaforma — si apre nel browser per salvare e trasferire + Il tuo dispositivo + Da trasferire 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 4b01ea662..c8888832d 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -1108,4 +1108,12 @@ GitHub で開く 既読にする 未読にする + すべてのプラットフォームを表示 + Android + Windows + macOS + Linux + 他のプラットフォーム — ブラウザで開き、転送用に保存します + このデバイス + 転送用 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 11a42a4e1..2bd8eb83d 100644 --- a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml +++ b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml @@ -1143,4 +1143,12 @@ GitHub에서 열기 본 항목으로 표시 본 항목 해제 + 모든 플랫폼 표시 + Android + Windows + macOS + Linux + 다른 플랫폼 — 전송용 저장을 위해 브라우저에서 열립니다 + 내 기기 + 전송용 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 2fbfce06d..2b1c8ca68 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -1134,4 +1134,12 @@ Otwórz w GitHub Oznacz jako obejrzane Oznacz jako nieobejrzane + Pokaż wszystkie platformy + Android + Windows + macOS + Linux + Inna platforma — otwiera się w przeglądarce, aby zapisać do transferu + Twoje urządzenie + Do transferu 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 825cbff54..6923c381c 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -1134,4 +1134,12 @@ Открыть на GitHub Отметить как просмотренный Снять отметку + Показать все платформы + Android + Windows + macOS + Linux + Другая платформа — открывается в браузере для сохранения и переноса + Это устройство + Для переноса 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 1ed48e73c..02ac744dd 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -1150,4 +1150,12 @@ GitHub\'da aç Görüldü olarak işaretle Görülmedi olarak işaretle + Tüm platformları göster + Android + Windows + macOS + Linux + Diğer platform — aktarma için kaydetmek üzere tarayıcıda açılır + Cihazınız + Aktarım için 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 f6c082f1e..875e6e1c3 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 @@ -1110,4 +1110,12 @@ 在 GitHub 打开 标记为已查看 取消已查看标记 + 显示所有平台 + Android + Windows + macOS + Linux + 其他平台 — 在浏览器中打开以保存并转移 + 你的设备 + 用于转移 diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 7b19efea3..1943cc855 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -775,6 +775,14 @@ Open on GitHub Mark as viewed Mark as unviewed + Show all platforms + Android + Windows + macOS + Linux + Other platform — opens in browser to save for transfer + Your device + For transfer Repository hidden Undo Hidden repositories diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt index 2e419c5e8..d9057a784 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt @@ -159,4 +159,20 @@ sealed interface DetailsAction { * Persists so the coachmark only ever shows once. */ data object OnAcknowledgeChannelChipCoachmark : DetailsAction + + /** + * Flip the "Show all platforms" picker setting (persisted globally). + * Carries the explicit target value so rapid back-and-forth toggles + * don't race against a stale read of the in-memory state. + */ + data class OnToggleShowAllPlatforms(val enabled: Boolean) : DetailsAction + + /** + * Download a non-current-platform asset for transfer to another + * device. Routes to the user's browser so the file lands in their + * normal Downloads folder. + */ + data class OnDownloadForTransfer( + val assetUrl: String, + ) : DetailsAction } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt index b0991ec24..108efa98f 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt @@ -134,6 +134,13 @@ data class DetailsState( * `ChannelChip` so users discover the per-app channel toggle. */ val isChannelChipCoachmarkPending: Boolean = false, + /** + * Mirrors `TweaksRepository.getShowAllPlatforms()`. When true the + * release-assets picker lists installers for every OS (grouped by + * section); the install button still operates on the current + * platform's primary asset. + */ + val showAllPlatforms: Boolean = false, ) { val filteredReleases: List get() = diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index 96b4e4e05..5591f23bd 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -147,6 +147,7 @@ class DetailsViewModel( observeApkInspectCoachmark() observeChannelChipCoachmark() observeCurrentUserForBadge() + observeShowAllPlatforms() hasLoadedInitialData = true } @@ -492,6 +493,29 @@ class DetailsViewModel( DetailsAction.OnAcknowledgeChannelChipCoachmark -> { acknowledgeChannelChipCoachmark() } + + is DetailsAction.OnToggleShowAllPlatforms -> { + viewModelScope.launch { + try { + tweaksRepository.setShowAllPlatforms(action.enabled) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + logger.warn("Toggle show-all-platforms failed: ${e.message}") + } + } + } + + is DetailsAction.OnDownloadForTransfer -> { + // Cross-platform assets land here when the user picks an + // installer for a different OS. Browser handles the + // actual save-to-Downloads — keeps install plumbing + // unchanged and matches existing "open external link" + // flows on Details. + helper.openUrl(action.assetUrl) { err -> + logger.warn("Open transfer download failed: $err") + } + } } } @@ -873,6 +897,14 @@ class DetailsViewModel( } } + private fun observeShowAllPlatforms() { + viewModelScope.launch { + tweaksRepository.getShowAllPlatforms().collect { enabled -> + _state.update { it.copy(showAllPlatforms = enabled) } + } + } + } + private fun observeChannelChipCoachmark() { viewModelScope.launch { val alreadyShown = diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/ReleaseAssetsPicker.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/ReleaseAssetsPicker.kt index bd89694a8..962c57d34 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/ReleaseAssetsPicker.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/ReleaseAssetsPicker.kt @@ -18,7 +18,10 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.UnfoldMore +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.outlined.Devices import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.CardDefaults import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api @@ -70,19 +73,35 @@ fun ReleaseAssetsPicker( selectedAsset: GithubAsset? = null, isPickerVisible: Boolean = false, pinnedVariant: String? = null, + showAllPlatforms: Boolean = false, + crossPlatformAssets: List = emptyList(), ) { - val isPickerEnabled by remember(assetsList) { - derivedStateOf { assetsList.isNotEmpty() } + // Decouple from `showAllPlatforms`: the toggle lives INSIDE the sheet, + // so disabling the open-card whenever the current branch is empty + // would lock the user out of flipping the setting back. Picker stays + // openable whenever either source has anything to show. + val isPickerEnabled by remember(assetsList, crossPlatformAssets) { + derivedStateOf { + assetsList.isNotEmpty() || crossPlatformAssets.isNotEmpty() + } } ReleaseAssetsItemsPicker( showPicker = isPickerVisible, assetsList = assetsList, + crossPlatformAssets = crossPlatformAssets, + showAllPlatforms = showAllPlatforms, selectedAsset = selectedAsset, pinnedVariant = pinnedVariant, onDismiss = { onAction(DetailsAction.ToggleReleaseAssetsPicker) }, onSelect = { onAction(DetailsAction.SelectDownloadAsset(it)) }, onUnpin = { onAction(DetailsAction.UnpinPreferredVariant) }, + onToggleShowAllPlatforms = { enabled -> + onAction(DetailsAction.OnToggleShowAllPlatforms(enabled)) + }, + onDownloadForTransfer = { asset -> + onAction(DetailsAction.OnDownloadForTransfer(asset.downloadUrl)) + }, ) Column( @@ -127,16 +146,20 @@ fun ReleaseAssetsPicker( } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable private fun ReleaseAssetsItemsPicker( assetsList: List, + crossPlatformAssets: List, + showAllPlatforms: Boolean, selectedAsset: GithubAsset?, pinnedVariant: String?, showPicker: Boolean, onDismiss: () -> Unit, onSelect: (GithubAsset) -> Unit, onUnpin: () -> Unit, + onToggleShowAllPlatforms: (Boolean) -> Unit, + onDownloadForTransfer: (GithubAsset) -> Unit, modifier: Modifier = Modifier, ) { if (!showPicker) return @@ -199,13 +222,106 @@ private fun ReleaseAssetsItemsPicker( } } - HorizontalDivider() + // Cross-platform toggle. Persisted globally — flipping here + // changes every Details screen's picker behaviour for this + // user. Off = current-OS assets only; On = grouped sections + // for Android / Windows / macOS / Linux. + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Row( + modifier = + Modifier + .clickable(onClick = { onToggleShowAllPlatforms(!showAllPlatforms) }) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Devices, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.size(12.dp)) + Text( + text = stringResource(Res.string.show_all_platforms_label), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + androidx.compose.material3.Switch( + checked = showAllPlatforms, + onCheckedChange = onToggleShowAllPlatforms, + ) + } + } + // Hoisted out of the LazyListScope below: `LazyColumn { … }` + // body is not a @Composable context, so `remember` calls have + // to live in the enclosing Column instead. + val groups = remember(crossPlatformAssets) { + crossPlatformAssets + .groupBy { + zed.rainxch.core.domain.util.assetPlatformOf(it.name) + } + .filterKeys { it != null } + .mapKeys { it.key!! } + } + val installableIds = remember(assetsList) { + assetsList.map { it.id }.toSet() + } LazyColumn( modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(vertical = 8.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - if (assetsList.isNotEmpty()) { + // Grouped path only when the toggle is on AND the release + // has platform-classifiable assets. If toggle is on but + // `assetPlatformOf` rejected every asset (e.g. release + // ships only .zip bundles / extensionless binaries), we + // fall through to the OFF-mode `assetsList` render so + // the user still sees the current-platform installables + // instead of an empty sheet. + if (showAllPlatforms && groups.isNotEmpty()) { + // Order: current-platform section first (it's the + // primary install target), then the others. + val sectionOrder = + listOf( + zed.rainxch.core.domain.model.DiscoveryPlatform.Android to Res.string.platform_section_android, + zed.rainxch.core.domain.model.DiscoveryPlatform.Windows to Res.string.platform_section_windows, + zed.rainxch.core.domain.model.DiscoveryPlatform.Macos to Res.string.platform_section_macos, + zed.rainxch.core.domain.model.DiscoveryPlatform.Linux to Res.string.platform_section_linux, + ).sortedByDescending { (platform, _) -> + groups[platform]?.any { it.id in installableIds } == true + } + sectionOrder.forEach { (platform, labelRes) -> + val assets = groups[platform].orEmpty() + if (assets.isEmpty()) return@forEach + val isCurrentDevice = + assets.any { it.id in installableIds } + item(key = "section-${platform.name}") { + PlatformSectionCard( + platformLabel = stringResource(labelRes), + isCurrentDevice = isCurrentDevice, + installableIds = installableIds, + assets = assets, + selectedAsset = selectedAsset, + pinnedVariant = pinnedVariant, + onAssetClick = { asset -> + if (asset.id in installableIds) { + onSelect(asset) + } else { + onDownloadForTransfer(asset) + } + }, + ) + } + } + } else if (assetsList.isNotEmpty()) { items(items = assetsList, key = { it.id }) { asset -> val variantTag = AssetVariant.extract(asset.name) val isPinned = @@ -366,3 +482,113 @@ private fun ReleaseAssetsPickerItemPreview() { isSelected = false, ) } + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun PlatformSectionCard( + platformLabel: String, + isCurrentDevice: Boolean, + installableIds: Set, + assets: List, + selectedAsset: GithubAsset?, + pinnedVariant: String?, + onAssetClick: (GithubAsset) -> Unit, +) { + OutlinedCard( + colors = + CardDefaults.outlinedCardColors( + containerColor = + if (isCurrentDevice) { + MaterialTheme.colorScheme.surfaceContainerLow + } else { + MaterialTheme.colorScheme.surfaceContainerLowest + }, + ), + shape = RoundedCornerShape(20.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = platformLabel, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + SectionChip( + label = + if (isCurrentDevice) { + stringResource(Res.string.section_chip_your_device) + } else { + stringResource(Res.string.section_chip_for_transfer) + }, + isPrimary = isCurrentDevice, + ) + } + + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(horizontal = 12.dp), + ) + + assets.forEachIndexed { index, asset -> + val isInstallableHere = asset.id in installableIds + val variantTag = AssetVariant.extract(asset.name) + val isPinned = + isInstallableHere && + !pinnedVariant.isNullOrBlank() && + variantTag?.equals(pinnedVariant, ignoreCase = true) == true + ReleaseAssetItem( + asset = asset, + isSelected = isInstallableHere && asset.id == selectedAsset?.id, + isPinned = isPinned, + onClick = { onAssetClick(asset) }, + modifier = Modifier.fillMaxWidth(), + ) + if (index < assets.lastIndex) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + } +} + +@Composable +private fun SectionChip( + label: String, + isPrimary: Boolean, +) { + val container = + if (isPrimary) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.tertiaryContainer + } + val content = + if (isPrimary) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onTertiaryContainer + } + Surface( + shape = RoundedCornerShape(8.dp), + color = container, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = content, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } +} diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Header.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Header.kt index 3db733dff..d713ead67 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Header.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Header.kt @@ -107,11 +107,26 @@ fun LazyListScope.header( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, ) { + // Memoize the platform-classifiable subset of release + // assets so each Header recompose (scroll, animation, + // unrelated state change) doesn't re-run the filter. + val crossPlatformAssets = + androidx.compose.runtime.remember(state.selectedRelease) { + state.selectedRelease + ?.assets + ?.filter { + zed.rainxch.core.domain.util + .assetPlatformOf(it.name) != null + } + .orEmpty() + } ReleaseAssetsPicker( assetsList = state.installableAssets, selectedAsset = state.primaryAsset, isPickerVisible = state.isReleaseSelectorVisible, pinnedVariant = state.installedApp?.preferredAssetVariant, + showAllPlatforms = state.showAllPlatforms, + crossPlatformAssets = crossPlatformAssets, onAction = onAction, modifier = Modifier.weight(.65f), )