diff --git a/shared/src/commonMain/composeResources/values-en/strings.xml b/shared/src/commonMain/composeResources/values-en/strings.xml index dcec1b3..ae4a971 100644 --- a/shared/src/commonMain/composeResources/values-en/strings.xml +++ b/shared/src/commonMain/composeResources/values-en/strings.xml @@ -1,4 +1,5 @@ + Bilby Back Cancel Already on this page @@ -396,6 +397,7 @@ System Light Dark + Start page Pure black Black background in dark theme Color diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index dd1cfce..3197107 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -1,4 +1,6 @@ + Bilby + 已在当前页面 @@ -479,6 +481,7 @@ 跟随系统 浅色 深色 + 默认启动页 纯黑背景 深色时页面底色为纯黑 配色 diff --git a/shared/src/commonMain/kotlin/dev/bilby/data/SettingsStore.kt b/shared/src/commonMain/kotlin/dev/bilby/data/SettingsStore.kt index e562f34..de94023 100644 --- a/shared/src/commonMain/kotlin/dev/bilby/data/SettingsStore.kt +++ b/shared/src/commonMain/kotlin/dev/bilby/data/SettingsStore.kt @@ -237,9 +237,15 @@ class SettingsStore(private val store: DataStore) { ?: ThemeMode.System, pureBlack = p[KEY_PURE_BLACK] ?: false, palette = p[KEY_THEME_PALETTE] ?: AppearancePrefs.DYNAMIC, + startTab = p[KEY_START_TAB]?.let { name -> StartTab.entries.firstOrNull { it.name == name } } + ?: StartTab.Search, ) } + val startTab: Flow = store.data.map { p -> + p[KEY_START_TAB]?.let { name -> StartTab.entries.firstOrNull { it.name == name } } ?: StartTab.Search + } + suspend fun saveThemeMode(mode: ThemeMode) { store.edit { p -> p[KEY_THEME_MODE] = mode.name } } @@ -253,6 +259,10 @@ class SettingsStore(private val store: DataStore) { store.edit { p -> p[KEY_THEME_PALETTE] = palette } } + suspend fun saveStartTab(tab: StartTab) { + store.edit { p -> p[KEY_START_TAB] = tab.name } + } + /** * 同时下几条缓存。为什么默认 1、上限 3,见 [dev.bilby.offline.OfflineDownloader] 的 * "并发度"一节 —— 这里只负责把用户选的那个数存下来。 @@ -452,6 +462,7 @@ class SettingsStore(private val store: DataStore) { private val KEY_THEME_MODE = stringPreferencesKey("theme_mode") private val KEY_PURE_BLACK = booleanPreferencesKey("theme_pure_black") private val KEY_THEME_PALETTE = stringPreferencesKey("theme_palette") + private val KEY_START_TAB = stringPreferencesKey("start_tab") private val KEY_SESSDATA = stringPreferencesKey("sessdata") private val KEY_BILI_JCT = stringPreferencesKey("bili_jct") private val KEY_DEDE_USER_ID = stringPreferencesKey("dede_user_id") @@ -758,6 +769,9 @@ data class PlayerPrefs( /** 明暗。三档互斥;纯黑是深色之下的另一个开关,不是第四档,见 [AppearancePrefs.pureBlack]。 */ enum class ThemeMode { System, Light, Dark } +/** 启动时默认落到哪一栏:动态、搜索着陆页或我的。 */ +enum class StartTab { Feed, Search, Profile } + data class AppearancePrefs( val mode: ThemeMode = ThemeMode.System, /** @@ -772,6 +786,7 @@ data class AppearancePrefs( * 指到别的颜色上。 */ val palette: String = DYNAMIC, + val startTab: StartTab = StartTab.Search, ) { companion object { const val DYNAMIC = "dynamic" diff --git a/shared/src/commonMain/kotlin/dev/bilby/ui/BilbyApp.kt b/shared/src/commonMain/kotlin/dev/bilby/ui/BilbyApp.kt index 56c574c..6874cd3 100644 --- a/shared/src/commonMain/kotlin/dev/bilby/ui/BilbyApp.kt +++ b/shared/src/commonMain/kotlin/dev/bilby/ui/BilbyApp.kt @@ -108,6 +108,7 @@ import dev.bilby.stringResource import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.bilby.data.AppearancePrefs +import dev.bilby.data.StartTab import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory @@ -1115,24 +1116,38 @@ private fun RootTabs( /** 搜索框里的编号或链接直接打开的那一页,可以是任何一种目的地。 */ onOpen: (NavKey) -> Unit, ) { - var selected by rememberSaveable { mutableStateOf(RootTab.Feed) } + val persistedStartTab by container.settings.startTab.collectAsStateWithLifecycle(initialValue = null) + var selected by rememberSaveable { mutableStateOf(RootTab.Search) } + var appliedStartTab by rememberSaveable { mutableStateOf(null) } + LaunchedEffect(persistedStartTab) { + val tab = persistedStartTab ?: return@LaunchedEffect + if (appliedStartTab != tab) { + appliedStartTab = tab + selected = when (tab) { + StartTab.Feed -> RootTab.Feed + StartTab.Search -> RootTab.Search + StartTab.Profile -> RootTab.Profile + } + } + } val windowSize = rememberBilbyWindowSize() - // 重按当前 tab 回到顶部,navigation-bar.md 的明文要求。 + // 重按当前 tab 回到这一页的起点,navigation-bar.md 的明文要求。 // // **每个 tab 一个计数器,不共用一个。** 共用的话,在动态页连点几下再切到个人页, // 个人页看到的是一个变过的计数器,会跟着滚一次它自己没被点过的。 // - // 搜索页不接:那一页是对话式的,输入在下、一轮轮结果往上滚,顶端是最早的一轮, - // 回到顶不是"重来一次",而是翻到最旧的地方。 + // 搜索页的"起点"是着陆页不是顶端:那一页是对话式的,没有"回到顶",搜过之后 + // 重按是清掉查询翻回居中的框 —— 与回顶同一件事,都是回到进来的那一屏。 var feedScrollToTop by remember { mutableIntStateOf(0) } var profileScrollToTop by remember { mutableIntStateOf(0) } + var searchHomeClicks by remember { mutableIntStateOf(0) } val onTabClick: (RootTab) -> Unit = { tab -> if (selected == tab) { when (tab) { RootTab.Feed -> feedScrollToTop++ RootTab.Profile -> profileScrollToTop++ - RootTab.Search -> Unit + RootTab.Search -> searchHomeClicks++ } } else { selected = tab @@ -1181,6 +1196,7 @@ private fun RootTabs( feedScrollToTop = feedScrollToTop, profileScrollToTop = profileScrollToTop, focusSearchNonce = focusSearchNonce, + searchHomeClicks = searchHomeClicks, container = container, onVideoClick = onVideoClick, onVideoInContext = onVideoInContext, @@ -1218,6 +1234,7 @@ private fun RootTabs( feedScrollToTop = feedScrollToTop, profileScrollToTop = profileScrollToTop, focusSearchNonce = focusSearchNonce, + searchHomeClicks = searchHomeClicks, container = container, onVideoClick = onVideoClick, onVideoInContext = onVideoInContext, @@ -1343,6 +1360,8 @@ private fun RootTabsContent( feedScrollToTop: Int, profileScrollToTop: Int, focusSearchNonce: Int, + /** 每变一次就翻回着陆页,与回顶同一语义(搜索页没有"顶端"可回)。 */ + searchHomeClicks: Int, container: AppContainer, onVideoClick: (String) -> Unit, onVideoInContext: (String, QueueContext) -> Unit, @@ -1416,7 +1435,15 @@ private fun RootTabsContent( ) // 不限宽:宽屏结果是格子、助理是侧栏,搜索框自己限宽(见 SearchChatScreen)。 - RootTab.Search -> SearchPane(container, focusSearchNonce, onVideoClick, onUserClick, onOpenArticle, onOpen) + RootTab.Search -> SearchPane( + container = container, + focusSearchNonce = focusSearchNonce, + searchHomeClicks = searchHomeClicks, + onVideoClick = onVideoClick, + onUserClick = onUserClick, + onOpenArticle = onOpenArticle, + onOpen = onOpen, + ) // 不限宽:宽屏每节是一排卡片,宽度换成条数(见 ProfileScreen);窄屏账号卡自己限宽。 RootTab.Profile -> ProfilePane( @@ -1518,6 +1545,8 @@ private fun FeedPane( private fun SearchPane( container: AppContainer, focusSearchNonce: Int, + /** 重按搜索 tab 的次数:每变一次回一次着陆页。 */ + searchHomeClicks: Int, onVideoClick: (String) -> Unit, onUserClick: (Long) -> Unit, onOpenArticle: (ArticleRef) -> Unit, @@ -1536,6 +1565,15 @@ private fun SearchPane( } }, ) + // **只认计数的变化,不认它的值。** 切走再切回来时 remember 重取当前值,effect 会再跑 + // 一趟但两次读数相同 —— 没有那一下按下,就不该把结果页翻回着陆页。 + var lastHomeClick by remember { mutableIntStateOf(searchHomeClicks) } + LaunchedEffect(searchHomeClicks) { + if (searchHomeClicks != lastHomeClick) { + lastHomeClick = searchHomeClicks + vm.backToLanding() + } + } val currentOnOpen by rememberUpdatedState(onOpen) LaunchedEffect(vm) { vm.open.collect { currentOnOpen(it) } } val state by vm.state.collectAsStateWithLifecycle() @@ -1551,6 +1589,7 @@ private fun SearchPane( onHistoryRemove = vm::removeSearchHistory, onHistoryClear = vm::clearSearchHistory, onSuggestionClick = vm::searchSuggestion, + onBackToLanding = vm::backToLanding, onInputChange = vm::onInputChange, onSearch = vm::search, onAskAgent = vm::askAgent, @@ -1765,6 +1804,7 @@ private fun SettingsPageRoute( onModeChange = vm::setThemeMode, onPureBlackChange = vm::setPureBlack, onPaletteChange = vm::setThemePalette, + onStartTabChange = vm::setStartTab, onLanguageChange = system::applyLanguage, onBack = onBack, ) diff --git a/shared/src/commonMain/kotlin/dev/bilby/ui/search/NormalSearchController.kt b/shared/src/commonMain/kotlin/dev/bilby/ui/search/NormalSearchController.kt index 5a0dbf3..0fb9040 100644 --- a/shared/src/commonMain/kotlin/dev/bilby/ui/search/NormalSearchController.kt +++ b/shared/src/commonMain/kotlin/dev/bilby/ui/search/NormalSearchController.kt @@ -255,6 +255,12 @@ class NormalSearchController( restart(userPager) } + /** 回到还没搜过的状态:三栏归零、查询抹掉,界面翻回着陆页。重按搜索 tab 走这里。 */ + fun clear() { + _state.update { it.copy(query = "") } + SearchTab.entries.forEach { pagerFor(it).reset(keepVisible = false) } + } + /** 重试和下拉刷新:当前栏从第一页重来,已有结果留在屏幕上等新页落地。 */ fun retry() { val s = _state.value diff --git a/shared/src/commonMain/kotlin/dev/bilby/ui/search/SearchChatScreen.kt b/shared/src/commonMain/kotlin/dev/bilby/ui/search/SearchChatScreen.kt index 90e4c21..6b84ad3 100644 --- a/shared/src/commonMain/kotlin/dev/bilby/ui/search/SearchChatScreen.kt +++ b/shared/src/commonMain/kotlin/dev/bilby/ui/search/SearchChatScreen.kt @@ -148,7 +148,8 @@ class AgentActions( /** * 搜索 tab:一个搜索框,回车是 B 站原始结果;助理在旁边另起一块。 * - * - **普通搜索是一个搜索页**:搜索栏在顶上,下面是结果(或历史)。 + * - **普通搜索有两张脸**:还没搜过是着陆页,字标加居中的框、下面是自己敲过的词; + * 搜过之后是结果页,搜索栏挪到顶上、下面是结果。 * - **助理是一段对话**:输入在下,一轮轮结果在上。它是进阶工具,不是每个人都用:模型没配时 * 它的入口一概不出现。 * @@ -178,10 +179,12 @@ fun SearchChatScreen( searchHistory: List = emptyList(), onHistoryClick: (String) -> Unit = {}, onHistoryRemove: (String) -> Unit = {}, - onHistoryClear: () -> Unit = {}, + onHistoryClear: ( ) -> Unit = {}, onSuggestionClick: (String) -> Unit = {}, /** 快捷键(`Ctrl+F` / `/`)请求聚焦主搜索框的计数器。 */ focusSearchNonce: Int = 0, + /** 结果页上侧滑返回:退到着陆页,不直接退出。 */ + onBackToLanding: () -> Unit = {}, modifier: Modifier = Modifier, ) { val wide = rememberBilbyWindowSize().isAtLeast(BilbyWindowSize.Expanded) @@ -219,6 +222,7 @@ fun SearchChatScreen( onSuggestionClick(term) }, focusSearchNonce = focusSearchNonce, + onBackToLanding = onBackToLanding, ) } @@ -268,6 +272,13 @@ private val AgentPanelMinWidth = 320.dp /** 宽屏搜索栏的最大宽度。再宽,左端的放大镜和右端的清除键就隔得太远,一眼看不全。 */ private val SearchBarMaxWidth = 720.dp +/** + * 着陆页的上下留白比。上 2 下 3:框的重心停在中线偏上,是搜索着陆页(Google、百度) + * 的通行摆法 —— 正中反而显得沉,偏上一点下面正好留给历史。 + */ +private const val LandingTopWeight = 2f +private const val LandingBottomWeight = 3f + /** 搜索栏行尾那颗侧栏开关的宽度(M3 图标按钮的触控格)。 */ private val PanelToggleWidth = 48.dp @@ -300,11 +311,13 @@ private fun AgentTopBar(onBack: () -> Unit) { } /** - * 普通搜索:搜索栏,下面是历史或结果。 + * 普通搜索的两张脸:**还没搜过是着陆页**,字标与框居中、下面是自己敲过的词; + * **搜过之后是结果页**,搜索栏在顶上、下面是结果。 * * **聚焦时就是 M3 search 的展开态**,建议(这里是历史)的摆法按窗口分:窄屏整块换成历史 * (full-screen 布局),宽屏从搜索栏下方垂下一张卡,底下的结果盖一层 scrim(docked 布局)。 - * search.md 原话是紧凑窗口用前者,中等以上换后者。 + * search.md 原话是紧凑窗口用前者,中等以上换后者。着陆页不适用这一节:它没有结果可盖, + * 历史直接摆在框下面,不弹展开态。 * * 窄屏的搜索栏随列表上滑收起、下滑露出(search.md Scroll 一节的第一种),结果多占一截屏。 * 宽屏不收:竖向不缺那 64dp,而侧栏开关长在这一行上。 @@ -326,6 +339,7 @@ private fun NormalSearch( suggestions: List, onSuggestionClick: (String) -> Unit, focusSearchNonce: Int = 0, + onBackToLanding: () -> Unit, ) { var focused by remember { mutableStateOf(false) } val focusManager = LocalFocusManager.current @@ -337,15 +351,13 @@ private fun NormalSearch( } // 展开态(搜索栏有焦点)时,返回先收起,不直接离开这一页。 BackHandler(enabled = focused) { focusManager.clearFocus() } + // 结果页上的返回退到着陆页,不直接退出;和上面那个互斥,聚焦时仍先收起。 + BackHandler(enabled = state.query.isNotEmpty() && !focused) { onBackToLanding() } // 宽屏的助理是常驻侧栏,不要这个按钮(见 [AskAgentFab])。 val askAgentFab = onAskAgent.takeIf { !wide } val fab = askAgentFab != null - val collapsing = remember { CollapsingBar() } - // 打字时和换了一次查询时栏一定在:前者正对着它,后者要看得见查的是什么。 - LaunchedEffect(focused, state.query) { collapsing.offsetPx = 0f } - val bar: @Composable ((@Composable (Modifier) -> Unit)?) -> Unit = { dropdown -> SearchBarRow( input = input, @@ -357,7 +369,7 @@ private fun NormalSearch( // 宽屏不给返回箭头:docked 布局点 scrim 就收起,箭头在桌面上是多余的一格。 showBack = !wide, wide = wide, - trailing = panelToggle, + trailing = panelToggle.takeUnless { state.query.isEmpty() && wide }, dropdown = dropdown, focusRequester = searchFocusRequester, ) @@ -365,47 +377,103 @@ private fun NormalSearch( val historyPanel: @Composable (Modifier) -> Unit = { panelModifier -> SearchHistoryPanel(history, onHistoryClick, onHistoryRemove, onHistoryClear, panelModifier) } + val suggestionPanel: @Composable (Modifier) -> Unit = { panelModifier -> + SuggestionPanel(suggestions, onSuggestionClick, panelModifier) + } // 聚焦时盖在结果上的那一块:正在打字而且有补全就是补全,否则是历史。 val typing = focused && input.isNotBlank() && suggestions.isNotEmpty() val focusPanel: (@Composable (Modifier) -> Unit)? = when { - typing -> { panelModifier -> SuggestionPanel(suggestions, onSuggestionClick, panelModifier) } + typing -> suggestionPanel history.isNotEmpty() -> historyPanel else -> null } val scrollingPanel: @Composable (@Composable (Modifier) -> Unit) -> Unit = { panel -> if (wide) { + val hasTrailingToggle = panelToggle != null && state.query.isNotEmpty() // 和搜索框同一条中线、同样宽,一眼对得上是这个框的历史与补全。照搜索栏那一行的 // 结构排([SearchBarRow]):行尾让出侧栏开关那一格,框在剩下那段里居中。按整个主区 // 居中的话,框和下面的历史各自对着不同的中线,左沿错开一截,历史看着像被收窄了。 Row( modifier = Modifier .fillMaxSize() - .padding(start = Spacing.Comfortable, end = if (panelToggle != null) Spacing.Hair else Spacing.Comfortable), + .padding(start = Spacing.Comfortable, end = if (hasTrailingToggle) Spacing.Hair else Spacing.Comfortable), ) { Box(modifier = Modifier.weight(1f).fillMaxHeight(), contentAlignment = Alignment.TopCenter) { panel(Modifier.widthIn(max = SearchBarMaxWidth).fillMaxSize().imePadding().verticalScroll(rememberScrollState())) } - if (panelToggle != null) Spacer(Modifier.width(PanelToggleWidth)) + if (hasTrailingToggle) Spacer(Modifier.width(PanelToggleWidth)) } } else { panel(Modifier.fillMaxSize().imePadding().verticalScroll(rememberScrollState())) } } + + // **还没搜过时是着陆页,不是顶栏版搜索页**:字标加居中搜索框,重心停在中线偏上, + // 框下面是自己敲过的词;打出一个字,那块换成补全。发出第一个查询之后才翻成下面的 + // 顶栏 + 结果,重按搜索 tab 回得来(见 RootTabs)。 + if (state.query.isEmpty()) { + val landingPanel = when { + input.isNotBlank() && suggestions.isNotEmpty() -> suggestionPanel + history.isNotEmpty() -> historyPanel + else -> null + } + Box(modifier = Modifier.fillMaxSize()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize(), + ) { + if (wide) { + val captionSlot = rememberCaptionSlot() + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = captionSlot.modifier + .fillMaxWidth() + .padding(horizontal = Spacing.Hair), + ) { + Spacer( + modifier = Modifier + .weight(1f) + .height(CaptionDragHeight) + .windowDragArea(captionSlot.atTop), + ) + panelToggle?.invoke() + captionSlot.buttons?.invoke() + } + } + Spacer(Modifier.weight(LandingTopWeight)) + Text( + text = stringResource(Res.string.app_name), + style = MaterialTheme.typography.displayMediumEmphasized, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(Spacing.Spacious)) + bar(null) + Box(modifier = Modifier.weight(LandingBottomWeight)) { + landingPanel?.let { scrollingPanel(it) } + } + } + if (!focused) { + askAgentFab?.let { + AskAgentFab( + onClick = it, + expanded = true, + modifier = Modifier.align(Alignment.BottomEnd), + ) + } + } + } + return + } + + val collapsing = remember { CollapsingBar() } + // 打字时和换了一次查询时栏一定在:前者正对着它,后者要看得见查的是什么。 + LaunchedEffect(focused, state.query) { collapsing.offsetPx = 0f } + val body: @Composable () -> Unit = { when { // 窄屏展开态:整块换成补全或历史(full-screen 布局)。 !wide && focused && focusPanel != null -> scrollingPanel(focusPanel) - // 还没搜过时历史就是正文。 - state.query.isEmpty() && history.isNotEmpty() -> scrollingPanel(historyPanel) - - state.query.isEmpty() -> Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - EmptyState( - message = stringResource(Res.string.search_empty), - icon = Icons.Outlined.Search, - ) - } - else -> Column(modifier = Modifier.fillMaxSize()) { uidCandidate(state.query)?.let { mid -> OpenUidRow(mid = mid, onClick = { actions.onUserClick(mid) }) @@ -422,8 +490,8 @@ private fun NormalSearch( } } } - // 宽屏展开态的卡片。历史已经是正文(还没搜过)时不再垂一份一样的下来。 - val dockedPanel = focusPanel.takeIf { focused && (typing || state.query.isNotEmpty()) } + // 宽屏展开态的卡片。 + val dockedPanel = focusPanel.takeIf { focused } if (wide) { // 搜索栏叠在最上层:展开时它向下长成一张卡,压在结果与 scrim 之上。 @@ -991,6 +1059,20 @@ private fun previewTrace(bvid: String, title: String) = TraceItem( upName = "某知名UP主", ) +@Preview +@Composable +private fun SearchChatScreenLandingPreview() { + BilbyTheme { + SearchChatScreen( + state = SearchChatUiState(mode = SearchMode.Normal), + onInputChange = {}, onSearch = {}, onAskAgent = {}, onLeaveAgent = {}, + agentActions = PreviewAgentActions, resultActions = PreviewResultActions, + agentAvailable = true, + searchHistory = listOf("宝可梦", "烹饪 一人食", "坂本龙一", "M3 Expressive"), + ) + } +} + @Preview @Composable private fun SearchChatScreenNormalPreview() { diff --git a/shared/src/commonMain/kotlin/dev/bilby/ui/search/SearchChatViewModel.kt b/shared/src/commonMain/kotlin/dev/bilby/ui/search/SearchChatViewModel.kt index b31802e..c1aad00 100644 --- a/shared/src/commonMain/kotlin/dev/bilby/ui/search/SearchChatViewModel.kt +++ b/shared/src/commonMain/kotlin/dev/bilby/ui/search/SearchChatViewModel.kt @@ -183,6 +183,16 @@ class SearchChatViewModel( /** 窄屏回到普通搜索。助理那段对话留着,再问一次还接得上。 */ fun leaveAgent() = _state.update { it.copy(mode = SearchMode.Normal) } + /** + * 重按搜索 tab 或在结果页返回:清掉当前查询与输入框,翻回着陆页(居中的框 + 自己敲过的词)。 + * 助理那一屏开着的话一并退回普通模式;助理的对话不动,它有自己的会话。 + */ + fun backToLanding() { + suggestJob?.cancel() + normal.clear() + _state.update { it.copy(input = "", suggestions = emptyList(), mode = SearchMode.Normal) } + } + /** * 普通搜索是一次查询一份结果,不累积轮次:它就是一个搜索页。留着上一次的结果只会让人往上翻, * 而翻上去的东西和这次要找的无关。排序沿用上一次选的那档 —— 新起一份 NormalSearchState() diff --git a/shared/src/commonMain/kotlin/dev/bilby/ui/settings/SettingsPages.kt b/shared/src/commonMain/kotlin/dev/bilby/ui/settings/SettingsPages.kt index 267bbbf..d4ba266 100644 --- a/shared/src/commonMain/kotlin/dev/bilby/ui/settings/SettingsPages.kt +++ b/shared/src/commonMain/kotlin/dev/bilby/ui/settings/SettingsPages.kt @@ -128,6 +128,7 @@ import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import dev.bilby.data.AppearancePrefs +import dev.bilby.data.StartTab import dev.bilby.data.ThemeMode import dev.bilby.ui.AppLanguage import dev.bilby.ui.LocalSystemActions @@ -594,6 +595,7 @@ fun AppearanceSettingsPage( onModeChange: (ThemeMode) -> Unit, onPureBlackChange: (Boolean) -> Unit, onPaletteChange: (String) -> Unit, + onStartTabChange: (StartTab) -> Unit = {}, onLanguageChange: (AppLanguage) -> Unit, onBack: (() -> Unit)?, ) { @@ -618,6 +620,9 @@ fun AppearanceSettingsPage( row { position -> PaletteRow(position = position, selected = appearance.palette, onSelect = onPaletteChange) } + row { position -> + StartTabRow(position = position, selected = appearance.startTab, onSelect = onStartTabChange) + } if (supportsLanguageSwitch) { row { position -> ChoiceRow( @@ -642,6 +647,45 @@ private val ThemeMode.label: StringResource ThemeMode.Dark -> Res.string.settings_theme_dark } +private val StartTab.label: StringResource + get() = when (this) { + StartTab.Feed -> Res.string.tab_feed + StartTab.Search -> Res.string.tab_search + StartTab.Profile -> Res.string.tab_profile + } + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun StartTabRow(position: RowPosition, selected: StartTab, onSelect: (StartTab) -> Unit) { + val options = StartTab.entries + ControlSettingRow( + position = position, + icon = Icons.Outlined.Start, + title = stringResource(Res.string.settings_start_tab), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), + modifier = Modifier.fillMaxWidth().selectableGroup(), + ) { + options.forEachIndexed { index, option -> + ToggleButton( + checked = option == selected, + onCheckedChange = { onSelect(option) }, + shapes = when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, + contentPadding = PaddingValues(horizontal = Spacing.Tight), + modifier = Modifier.weight(1f).semantics { role = Role.RadioButton }, + ) { + Text(stringResource(option.label), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + /** * 明暗三档,连体按钮组。写法与播放器设置面板的 ConnectedChoices 相同:各格等宽, * 内边距收小,读屏按单选念。 diff --git a/shared/src/commonMain/kotlin/dev/bilby/ui/settings/SettingsViewModel.kt b/shared/src/commonMain/kotlin/dev/bilby/ui/settings/SettingsViewModel.kt index 59c78ba..57f018f 100644 --- a/shared/src/commonMain/kotlin/dev/bilby/ui/settings/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/dev/bilby/ui/settings/SettingsViewModel.kt @@ -3,6 +3,7 @@ package dev.bilby.ui.settings import dev.bilby.api.BiliResult import dev.bilby.data.HistoryRepository import dev.bilby.data.AppearancePrefs +import dev.bilby.data.StartTab import dev.bilby.data.ThemeMode import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -349,6 +350,11 @@ class SettingsViewModel( persist { settings.saveThemePalette(palette) } } + fun setStartTab(tab: StartTab) { + _state.update { it.copy(appearance = it.appearance.copy(startTab = tab)) } + persist { settings.saveStartTab(tab) } + } + fun setOfflineConcurrency(value: Int) { _state.update { it.copy(offlineConcurrency = value) } persist { settings.saveOfflineConcurrency(value) }