From f345c6f1ac3f393fbc0b1aea6f25ab8a65c61b43 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Mon, 31 Aug 2026 13:43:58 +0800 Subject: [PATCH 01/13] feat: add large-screen layout modes --- .../dev/dimension/flare/ui/route/Router.kt | 15 +- .../flare/ui/screen/home/HomeScreen.kt | 224 +++--- .../ui/screen/home/HomeSecondarySidebar.kt | 234 +++++++ .../screen/settings/AppearanceLayoutScreen.kt | 35 +- app/src/main/res/values-zh-rCN/strings.xml | 5 + app/src/main/res/values-zh-rTW/strings.xml | 5 + app/src/main/res/values/strings.xml | 5 + .../AppearanceSettingsSections.swift | 17 +- .../Resources/Localizable.xcstrings | 110 +++ appleApp/ios/UI/FlareRoot.swift | 356 ++++++++-- appleApp/ios/UI/Route/Router.swift | 54 +- .../values-zh-rCN/strings.xml | 5 + .../values-zh-rTW/strings.xml | 5 + .../main/composeResources/values/strings.xml | 5 + .../main/kotlin/dev/dimension/flare/App.kt | 651 ++++++++++-------- .../dimension/flare/HomeSecondarySidebar.kt | 187 +++++ .../dev/dimension/flare/ui/route/Router.kt | 15 +- .../ui/screen/settings/SettingsScreen.kt | 51 +- .../data/model/appearance/AppearanceKeys.kt | 7 + .../data/model/appearance/AppearanceModels.kt | 24 +- .../flare/ui/presenter/SettingsPresenter.kt | 20 +- .../presenter/home/SecondaryTabsPresenter.kt | 3 + .../model/appearance/AppearancePatchTest.kt | 27 +- 23 files changed, 1572 insertions(+), 488 deletions(-) create mode 100644 app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/HomeSecondarySidebar.kt diff --git a/app/src/main/java/dev/dimension/flare/ui/route/Router.kt b/app/src/main/java/dev/dimension/flare/ui/route/Router.kt index 81a75c7034..e12d52a3fc 100644 --- a/app/src/main/java/dev/dimension/flare/ui/route/Router.kt +++ b/app/src/main/java/dev/dimension/flare/ui/route/Router.kt @@ -77,6 +77,7 @@ internal fun Router( onBack: () -> Unit, openDrawer: () -> Unit, modifier: Modifier = Modifier, + singlePane: Boolean = false, ) { val listDetailStrategy = rememberListDetailSceneStrategy() val isBigScreen = isBigScreen() @@ -161,12 +162,14 @@ internal fun Router( entryProvider = navEntryProvider, ) val sceneStrategies = - remember(listDetailStrategy) { - listOf( - DialogSceneStrategy(), - BottomSheetSceneStrategy(), - listDetailStrategy, - ) + remember(listDetailStrategy, singlePane) { + buildList { + add(DialogSceneStrategy()) + add(BottomSheetSceneStrategy()) + if (!singlePane) { + add(listDetailStrategy) + } + } } val predictiveBackSceneDecorator = rememberAndroidPredictiveBackSceneDecorator(predictiveBackMotionState) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt index d8c20ac724..4079cd7de9 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt @@ -4,10 +4,14 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.material3.Badge import androidx.compose.material3.ExperimentalMaterial3Api @@ -18,6 +22,7 @@ import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider import androidx.compose.material3.WideNavigationRailState import androidx.compose.material3.WideNavigationRailValue import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 @@ -58,16 +63,20 @@ import compose.icons.fontawesomeicons.solid.PenToSquare import compose.icons.fontawesomeicons.solid.Robot import compose.icons.fontawesomeicons.solid.SquareRss import dev.dimension.flare.R +import dev.dimension.flare.data.model.appearance.LargeScreenLayoutMode import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.common.OnNewIntent import dev.dimension.flare.ui.common.isLoginCallbackDeepLink import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.InAppNotificationComponent +import dev.dimension.flare.ui.component.LocalGlobalAppearance import dev.dimension.flare.ui.component.NavigationSuiteScaffold2 import dev.dimension.flare.ui.component.RichText import dev.dimension.flare.ui.component.TabIcon import dev.dimension.flare.ui.component.TopLevelBackStack +import dev.dimension.flare.ui.component.platform.LocalWindowSizeClass +import dev.dimension.flare.ui.component.platform.WindowSizeClass import dev.dimension.flare.ui.model.asText import dev.dimension.flare.ui.model.asType import dev.dimension.flare.ui.model.map @@ -104,6 +113,7 @@ internal fun HomeScreen(afterInit: () -> Unit) { state.updateUriHandler(uriHandler) } val hapticFeedback = LocalHapticFeedback.current + val globalAppearance = LocalGlobalAppearance.current state.tabs .onSuccess { tabs -> val currentRoute = @@ -127,7 +137,14 @@ internal fun HomeScreen(afterInit: () -> Unit) { NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo( currentWindowAdaptiveInfoV2(), ) - Box { + BoxWithConstraints { + val singleColumn = + globalAppearance.largeScreenLayoutMode == LargeScreenLayoutMode.SingleColumn && + layoutType != NavigationSuiteType.NavigationBar + val showRightSidebar = + singleColumn && + maxWidth >= 1024.dp && + state.wideNavigationRailState.currentValue == WideNavigationRailValue.Collapsed NavigationSuiteScaffold2( wideNavigationRailState = state.wideNavigationRailState, modifier = Modifier.fillMaxSize(), @@ -277,7 +294,7 @@ internal fun HomeScreen(afterInit: () -> Unit) { } }, secondaryItems = { - if (layoutType != NavigationSuiteType.NavigationBar) { + if (layoutType != NavigationSuiteType.NavigationBar && !showRightSidebar) { item( selected = currentRoute is Route.DraftBox, onClick = { @@ -341,50 +358,51 @@ internal fun HomeScreen(afterInit: () -> Unit) { ) } } - state.secondaryTabsState.onSuccess { secondaryTabs -> - secondaryTabs.forEach { item -> - expandableItem( - icon = { - item.user.onSuccess { - AvatarComponent(it.avatar) - } - }, - label = { - Column { + if (!showRightSidebar) { + state.secondaryTabsState.onSuccess { secondaryTabs -> + secondaryTabs.forEach { item -> + expandableItem( + icon = { item.user.onSuccess { - RichText(it.name) - Text( - it.handle.canonical, - style = MaterialTheme.typography.bodySmall, - ) + AvatarComponent(it.avatar) } - } - }, - children = { - item.tabs.forEach { - val direction = getDirection(it) - if (direction != null) { - item( - selected = currentRoute == direction, - onClick = { - if (currentRoute == direction) { - state.scrollToTopRegistry.scrollToTop() - } else { - state.navigate(direction) - } - }, - icon = { - TabIcon( - icon = it.icon.asType(), - title = it.title.asText(), - iconOnly = true, - ) - }, - label = { - dev.dimension.flare.ui.component.Text( - text = it.title.asText(), - ) - }, + }, + label = { + Column { + item.user.onSuccess { + RichText(it.name) + Text( + it.handle.canonical, + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + children = { + item.tabs.forEach { + val direction = getDirection(it) + if (direction != null) { + item( + selected = currentRoute == direction, + onClick = { + if (currentRoute == direction) { + state.scrollToTopRegistry.scrollToTop() + } else { + state.navigate(direction) + } + }, + icon = { + TabIcon( + icon = it.icon.asType(), + title = it.title.asText(), + iconOnly = true, + ) + }, + label = { + dev.dimension.flare.ui.component.Text( + text = it.title.asText(), + ) + }, // badge = // if (it is AllNotificationTabItem) { // { @@ -397,11 +415,12 @@ internal fun HomeScreen(afterInit: () -> Unit) { // } else { // null // }, - ) + ) + } } - } - }, - ) + }, + ) + } } } }, @@ -470,21 +489,23 @@ internal fun HomeScreen(afterInit: () -> Unit) { ) } } - item( - selected = currentRoute is Route.Settings.Main, - onClick = { - state.navigate(Route.Settings.Main) - }, - icon = { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Gear, - contentDescription = stringResource(id = R.string.settings_title), - ) - }, - label = { - Text(text = stringResource(id = R.string.settings_title)) - }, - ) + if (!showRightSidebar) { + item( + selected = currentRoute is Route.Settings.Main, + onClick = { + state.navigate(Route.Settings.Main) + }, + icon = { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Gear, + contentDescription = stringResource(id = R.string.settings_title), + ) + }, + label = { + Text(text = stringResource(id = R.string.settings_title)) + }, + ) + } }, ) { CompositionLocalProvider( @@ -498,20 +519,62 @@ internal fun HomeScreen(afterInit: () -> Unit) { }, LocalScrollToTopRegistry provides state.scrollToTopRegistry, ) { - Router( - backStack = - state.topLevelBackStack.takeSuccess()?.backStack - ?: remember { - androidx.compose.runtime.mutableStateListOf( - currentRoute, + val backStack = + state.topLevelBackStack.takeSuccess()?.backStack + ?: remember { + androidx.compose.runtime.mutableStateListOf( + currentRoute, + ) + } + if (singleColumn) { + Row( + modifier = Modifier.fillMaxSize(), + ) { + Box( + modifier = + Modifier + .weight(1f) + .fillMaxHeight(), + contentAlignment = Alignment.TopCenter, + ) { + CompositionLocalProvider( + LocalWindowSizeClass provides WindowSizeClass.Compact, + ) { + Router( + backStack = backStack, + openDrawer = state::openDrawer, + navigate = state::navigate, + onBack = state::goBack, + modifier = + Modifier + .width(480.dp) + .fillMaxHeight(), + singlePane = true, ) - }, - openDrawer = { - state.openDrawer() - }, - navigate = state::navigate, - onBack = state::goBack, - ) + } + } + if (showRightSidebar) { + VerticalDivider() + HomeSecondarySidebar( + secondaryTabs = state.secondaryTabsState, + isLoggedIn = state.loggedInState.takeSuccess(), + aiAgentEnabled = state.aiAgentEnabled, + currentRoute = + state.topLevelBackStack.takeSuccess()?.currentKey + ?: currentRoute, + navigate = state::navigateSecondary, + modifier = Modifier.width(336.dp), + ) + } + } + } else { + Router( + backStack = backStack, + openDrawer = state::openDrawer, + navigate = state::navigate, + onBack = state::goBack, + ) + } } } InAppNotificationComponent( @@ -526,7 +589,7 @@ internal fun HomeScreen(afterInit: () -> Unit) { } } -private fun getDirection(data: SecondaryTabsPresenter.Tab): Route? = +internal fun getDirection(data: SecondaryTabsPresenter.Tab): Route? = when (val target = data.destination) { is SecondaryTabsPresenter.Destination.Route -> { Route.from(target.route) @@ -662,6 +725,13 @@ private fun presenter(uriHandler: UriHandler) = ) } + fun navigateSecondary(route: Route) { + topLevelBackStack.takeSuccess()?.add(route) + scope.launch { + wideNavigationRailState.collapse() + } + } + fun goBack() { topLevelBackStack.takeSuccess()?.removeLast() } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt new file mode 100644 index 0000000000..8182366e6a --- /dev/null +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt @@ -0,0 +1,234 @@ +package dev.dimension.flare.ui.screen.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.ClockRotateLeft +import compose.icons.fontawesomeicons.solid.Gear +import compose.icons.fontawesomeicons.solid.MagnifyingGlass +import compose.icons.fontawesomeicons.solid.PenToSquare +import compose.icons.fontawesomeicons.solid.Robot +import compose.icons.fontawesomeicons.solid.SquareRss +import compose.icons.fontawesomeicons.solid.UserPlus +import dev.dimension.flare.R +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.ui.component.AvatarComponent +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.RichText +import dev.dimension.flare.ui.component.TabIcon +import dev.dimension.flare.ui.model.UiState +import dev.dimension.flare.ui.model.asText +import dev.dimension.flare.ui.model.asType +import dev.dimension.flare.ui.model.takeSuccess +import dev.dimension.flare.ui.presenter.home.SecondaryTabsPresenter +import dev.dimension.flare.ui.route.Route +import kotlinx.collections.immutable.ImmutableList + +private val SidebarHorizontalPadding = 16.dp + +@Composable +internal fun HomeSecondarySidebar( + secondaryTabs: UiState>, + isLoggedIn: Boolean?, + aiAgentEnabled: Boolean, + currentRoute: Route, + navigate: (Route) -> Unit, + modifier: Modifier = Modifier, +) { + val accounts = secondaryTabs.takeSuccess().orEmpty() + val searchAccount = accounts.firstOrNull()?.accountType ?: AccountType.Guest + var query by remember { mutableStateOf("") } + val keyboardController = LocalSoftwareKeyboardController.current + val submitSearch = { + navigate(Route.Search(accountType = searchAccount, query = query.trim())) + keyboardController?.hide() + Unit + } + + LazyColumn( + modifier = modifier.fillMaxHeight(), + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + item { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = SidebarHorizontalPadding), + placeholder = { Text(stringResource(R.string.discover_search_placeholder)) }, + singleLine = true, + leadingIcon = { + FAIcon( + imageVector = FontAwesomeIcons.Solid.MagnifyingGlass, + contentDescription = null, + ) + }, + trailingIcon = { + IconButton(onClick = submitSearch) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.MagnifyingGlass, + contentDescription = stringResource(R.string.discover_search_placeholder), + ) + } + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { submitSearch() }), + ) + } + + if (isLoggedIn == false) { + item { + SidebarItem( + label = stringResource(R.string.login_button), + selected = currentRoute is Route.ServiceSelect, + icon = FontAwesomeIcons.Solid.UserPlus, + onClick = { navigate(Route.ServiceSelect.Selection) }, + ) + } + } + + accounts.forEach { account -> + val profileRoute = account.tabs.firstOrNull()?.let(::getDirection) + item(key = "account-${account.accountType}") { + NavigationDrawerItem( + label = { + Column { + account.user.takeSuccess()?.let { user -> + RichText(user.name, maxLines = 1) + Text( + user.handle.canonical, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + ) + } + } + }, + selected = profileRoute != null && currentRoute == profileRoute, + onClick = { profileRoute?.let(navigate) }, + icon = { + account.user.takeSuccess()?.let { user -> + AvatarComponent(user.avatar) + } + }, + modifier = Modifier.padding(horizontal = SidebarHorizontalPadding), + ) + } + account.tabs.drop(1).forEach { tab -> + val route = getDirection(tab) ?: return@forEach + item(key = "${account.accountType}-$route") { + NavigationDrawerItem( + label = { + dev.dimension.flare.ui.component + .Text(tab.title.asText()) + }, + selected = currentRoute == route, + onClick = { navigate(route) }, + icon = { + TabIcon( + icon = tab.icon.asType(), + title = tab.title.asText(), + iconOnly = true, + ) + }, + modifier = + Modifier.padding( + start = SidebarHorizontalPadding + 24.dp, + end = SidebarHorizontalPadding, + ), + ) + } + } + } + + item { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + item { + SidebarItem( + label = stringResource(R.string.draft_box_title), + selected = currentRoute is Route.DraftBox, + icon = FontAwesomeIcons.Solid.PenToSquare, + onClick = { navigate(Route.DraftBox) }, + ) + } + item { + SidebarItem( + label = stringResource(R.string.settings_rss_management_title), + selected = currentRoute is Route.Rss.Sources, + icon = FontAwesomeIcons.Solid.SquareRss, + onClick = { navigate(Route.Rss.Sources) }, + ) + } + item { + SidebarItem( + label = stringResource(R.string.settings_local_history_title), + selected = currentRoute is Route.Settings.LocalHistory, + icon = FontAwesomeIcons.Solid.ClockRotateLeft, + onClick = { navigate(Route.Settings.LocalHistory) }, + ) + } + if (aiAgentEnabled) { + item { + SidebarItem( + label = stringResource(R.string.agent_history_title), + selected = currentRoute is Route.Settings.AgentHistory, + icon = FontAwesomeIcons.Solid.Robot, + onClick = { navigate(Route.Settings.AgentHistory) }, + ) + } + } + item { + SidebarItem( + label = stringResource(R.string.settings_title), + selected = currentRoute is Route.Settings.Main, + icon = FontAwesomeIcons.Solid.Gear, + onClick = { navigate(Route.Settings.Main) }, + ) + } + } +} + +@Composable +private fun SidebarItem( + label: String, + selected: Boolean, + icon: androidx.compose.ui.graphics.vector.ImageVector, + onClick: () -> Unit, +) { + NavigationDrawerItem( + label = { Text(label) }, + selected = selected, + onClick = onClick, + icon = { + FAIcon(imageVector = icon, contentDescription = label) + }, + modifier = Modifier.padding(horizontal = SidebarHorizontalPadding), + ) +} diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/AppearanceLayoutScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/AppearanceLayoutScreen.kt index a6ae046884..bef7c7ef61 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/AppearanceLayoutScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/AppearanceLayoutScreen.kt @@ -28,6 +28,7 @@ import dev.dimension.flare.data.model.BottomBarStyle import dev.dimension.flare.data.model.PostActionStyle import dev.dimension.flare.data.model.TimelineDisplayMode import dev.dimension.flare.data.model.appearance.AppearanceKeys +import dev.dimension.flare.data.model.appearance.LargeScreenLayoutMode import dev.dimension.flare.ui.component.BackButton import dev.dimension.flare.ui.component.FlareLargeFlexibleTopAppBar import dev.dimension.flare.ui.component.FlareScaffold @@ -131,25 +132,25 @@ internal fun AppearanceLayoutScreen( ) }, ) - SegmentedListItem( - onClick = { - state.update(AppearanceKeys.DeckMode, !globalAppearance.deckMode) - }, - shapes = ListItemDefaults.item(), - content = { - Text(text = stringResource(id = R.string.settings_appearance_deck_mode)) - }, - supportingContent = { - Text(text = stringResource(id = R.string.settings_appearance_deck_mode_description)) + SingleChoiceSettingsItem( + headline = { Text(text = stringResource(id = R.string.settings_appearance_large_screen_layout)) }, + supporting = { + Text(text = stringResource(id = R.string.settings_appearance_large_screen_layout_description)) }, - trailingContent = { - Switch( - checked = globalAppearance.deckMode, - onCheckedChange = { - state.update(AppearanceKeys.DeckMode, it) - }, - ) + items = + persistentMapOf( + LargeScreenLayoutMode.Auto to + stringResource(id = R.string.settings_appearance_large_screen_layout_auto), + LargeScreenLayoutMode.Deck to + stringResource(id = R.string.settings_appearance_large_screen_layout_deck), + LargeScreenLayoutMode.SingleColumn to + stringResource(id = R.string.settings_appearance_large_screen_layout_single_column), + ), + selected = globalAppearance.largeScreenLayoutMode, + onSelected = { + state.update(AppearanceKeys.LargeScreenLayout, it) }, + shapes = ListItemDefaults.item(), ) SingleChoiceSettingsItem( headline = { Text(text = stringResource(id = R.string.settings_appearance_timeline_display_mode)) }, diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 159492559e..55d79947bc 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -351,6 +351,11 @@ 在底部导航和侧边栏显示文字标签 分栏模式 (Deck) 在支持的时间轴上使用分栏布局 + 大屏幕布局 + 选择 Flare 在大屏幕上如何使用空间 + 自动 + 多栏模式 + 单栏 应用内浏览器 在 Flare 内部打开外部链接 全宽动态 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index bcecaab552..5835f0f450 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -303,6 +303,11 @@ 在底部導覽和側邊導覽中顯示文字標籤 多欄模式 為支援的時間軸使用多欄佈局 + 大螢幕佈局 + 選擇 Flare 在大螢幕上如何使用空間 + 自動 + 多欄模式 + 單欄 內建瀏覽器 在 Flare 內部開啟外部連結 全寬貼文 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fbc7bfc0b7..2e59e3b245 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -410,6 +410,11 @@ Show text labels in bottom navigation and the side rail Deck mode Use a deck layout for supported timelines + Large-screen layout + Choose how Flare uses space on larger screens + Auto + Deck mode + Single column In-app browser Open external links inside Flare Full-width posts diff --git a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/AppearanceSettingsSections.swift b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/AppearanceSettingsSections.swift index 8aad2f8177..0de8f811a1 100644 --- a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/AppearanceSettingsSections.swift +++ b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/AppearanceSettingsSections.swift @@ -90,13 +90,20 @@ public struct AppearanceLayoutSettingsSection: View Text("appearance_show_bottom_bar_labels", bundle: FlareAppleUILocalization.bundle) Text("appearance_show_bottom_bar_labels_description", bundle: FlareAppleUILocalization.bundle) } - Toggle(isOn: Binding(get: { - globalAppearance.deckMode + Picker(selection: Binding(get: { + globalAppearance.largeScreenLayoutMode }, set: { newValue in - presenter.state.updateDeckMode(value: newValue) + presenter.state.updateLargeScreenLayoutMode(value: newValue) })) { - Text("appearance_deck_mode", bundle: FlareAppleUILocalization.bundle) - Text("appearance_deck_mode_description", bundle: FlareAppleUILocalization.bundle) + Text("appearance_large_screen_layout_auto", bundle: FlareAppleUILocalization.bundle) + .tag(LargeScreenLayoutMode.auto) + Text("appearance_large_screen_layout_deck", bundle: FlareAppleUILocalization.bundle) + .tag(LargeScreenLayoutMode.deck) + Text("appearance_large_screen_layout_single_column", bundle: FlareAppleUILocalization.bundle) + .tag(LargeScreenLayoutMode.singleColumn) + } label: { + Text("appearance_large_screen_layout", bundle: FlareAppleUILocalization.bundle) + Text("appearance_large_screen_layout_description", bundle: FlareAppleUILocalization.bundle) } #endif } diff --git a/appleApp/Shared/FlareAppleResource/Resources/Localizable.xcstrings b/appleApp/Shared/FlareAppleResource/Resources/Localizable.xcstrings index 0b5e0fb611..dd6a468106 100644 --- a/appleApp/Shared/FlareAppleResource/Resources/Localizable.xcstrings +++ b/appleApp/Shared/FlareAppleResource/Resources/Localizable.xcstrings @@ -1,6 +1,116 @@ { "sourceLanguage" : "en", "strings" : { + "appearance_large_screen_layout" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Large-screen layout" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "大屏幕布局" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "大螢幕佈局" + } + } + } + }, + "appearance_large_screen_layout_auto" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auto" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "自動" + } + } + } + }, + "appearance_large_screen_layout_deck" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deck mode" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "多栏模式" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "多欄模式" + } + } + } + }, + "appearance_large_screen_layout_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose how Flare uses space on larger screens" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择 Flare 在大屏幕上如何使用空间" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇 Flare 在大螢幕上如何使用空間" + } + } + } + }, + "appearance_large_screen_layout_single_column" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Single column" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "单栏" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "單欄" + } + } + } + }, "" : { "localizations" : { "af" : { diff --git a/appleApp/ios/UI/FlareRoot.swift b/appleApp/ios/UI/FlareRoot.swift index d003fd7e80..0dcffe6c88 100644 --- a/appleApp/ios/UI/FlareRoot.swift +++ b/appleApp/ios/UI/FlareRoot.swift @@ -20,63 +20,75 @@ struct FlareRoot: View { @StateObject private var secondaryTabsPresenter = KotlinPresenter(presenter: SecondaryTabsPresenter()) @StateObject private var aiAgentEnabledPresenter = KotlinPresenter(presenter: AiAgentEnabledPresenter()) @StateObject private var inAppNotification = SwiftInAppNotification.shared + @StateObject private var navigationModel = HomeNavigationModel() @State var selectedTab: String? @State private var reloginRoute: Route? var body: some View { StateView(state: homeTabsPresenter.state.tabs) { tabs in let items = tabs.cast(HomeTabsPresenterStateHomeTabs.self) - TabView(selection: $selectedTab) { - ForEach(items, id: \.name) { tab in - Tab(value: homeTabKey(tab), role: homeTabRoute(tab) == .discover ? .some(.search) : .none) { - Router { onNavigate in - homeTabRoute(tab).view(onNavigate: onNavigate, goBack: {}) - } - } label: { - Label { - Text(homeTabTitle(tab)) - } icon: { - Image(fontAwesome: homeTabIcon(tab)) + Group { + if globalAppearance.largeScreenLayoutMode == .singleColumn && horizontalSizeClass == .regular { + SingleColumnFlareRoot( + tabs: items, + selectedTab: $selectedTab, + notificationCount: Int(notificationBadgePresenter.state.count), + navigationModel: navigationModel + ) + } else { + TabView(selection: $selectedTab) { + ForEach(items, id: \.name) { tab in + Tab(value: homeTabKey(tab), role: homeTabRoute(tab) == .discover ? .some(.search) : .none) { + Router(backStack: navigationModel.binding(for: homeTabKey(tab))) { onNavigate in + homeTabRoute(tab).view(onNavigate: onNavigate, goBack: {}) + } + } label: { + Label { + Text(homeTabTitle(tab)) + } icon: { + Image(fontAwesome: homeTabIcon(tab)) + } + .adaptiveLabelStyle(globalAppearance.showBottomBarLabels || horizontalSizeClass == .regular) + } + .badge(homeTabRoute(tab) == .notification ? Int(notificationBadgePresenter.state.count) : 0) } - .adaptiveLabelStyle(globalAppearance.showBottomBarLabels || horizontalSizeClass == .regular) - } - .badge(homeTabRoute(tab) == .notification ? Int(notificationBadgePresenter.state.count) : 0) - } - if horizontalSizeClass == .regular { - if case .success(let data) = onEnum(of: secondaryTabsPresenter.state.items) { - let items = data.data.cast(SecondaryTabsPresenter.Item.self) - ForEach(Array(items.enumerated()), id: \.offset) { _, item in - TabSection { - ForEach(item.tabs, id: \.self) { tab in - if let route = route(for: tab) { - secondarySidebarShortcut(tab, route: route) + if horizontalSizeClass == .regular { + if case .success(let data) = onEnum(of: secondaryTabsPresenter.state.items) { + let items = data.data.cast(SecondaryTabsPresenter.Item.self) + ForEach(Array(items.enumerated()), id: \.offset) { _, item in + TabSection { + ForEach(item.tabs, id: \.self) { tab in + if let route = route(for: tab) { + secondarySidebarShortcut(tab, route: route) + } + } + } header: { + StateView(state: item.user) { user in + UserOnelineView(data: user) + } errorContent: { _ in + Text("account_management_title") + } loadingContent: { + Text("account_management_title") + } } + .tabPlacement(.sidebarOnly) } - } header: { - StateView(state: item.user) { user in - UserOnelineView(data: user) - } errorContent: { _ in - Text("account_management_title") - } loadingContent: { - Text("account_management_title") - } } - .tabPlacement(.sidebarOnly) + ForEach(SecondarySidebarStaticRoute.allCases.filter { route in + route != .agentHistory || aiAgentEnabledPresenter.state.enabled + }, id: \.self) { route in + secondarySidebarStaticRoute(route) + } } } - ForEach(SecondarySidebarStaticRoute.allCases.filter { route in - route != .agentHistory || aiAgentEnabledPresenter.state.enabled - }, id: \.self) { route in - secondarySidebarStaticRoute(route) - } + .modifier(TabBarDoubleTapModifier { + NotificationCenter.default.post(name: .tabDoubleTapped, object: selectedTab) + }) + .tabViewStyle(.sidebarAdaptable) + .backport + .tabBarMinimizeBehavior(.onScrollDown) } } - .modifier(TabBarDoubleTapModifier { - NotificationCenter.default.post(name: .tabDoubleTapped, object: selectedTab) - }) - .tabViewStyle(.sidebarAdaptable) - .backport - .tabBarMinimizeBehavior(.onScrollDown) .background(Color(.systemGroupedBackground)) .sheet(item: $reloginRoute) { route in NavigationStack { @@ -140,32 +152,44 @@ struct BackportFlareRoot: View { @StateObject private var homeTabsPresenter = KotlinPresenter(presenter: HomeTabsPresenter()) @StateObject private var notificationBadgePresenter = KotlinPresenter(presenter: AllNotificationBadgePresenter()) @StateObject private var inAppNotification = SwiftInAppNotification.shared + @StateObject private var navigationModel = HomeNavigationModel() @State var selectedTab: String? @State private var reloginRoute: Route? var body: some View { StateView(state: homeTabsPresenter.state.tabs) { tabs in let items = tabs.cast(HomeTabsPresenterStateHomeTabs.self) - TabView(selection: $selectedTab) { - ForEach(items, id: \.name) { tab in - Router { onNavigate in - homeTabRoute(tab).view(onNavigate: onNavigate, goBack: {}) - } - .tabItem { - Label { - Text(homeTabTitle(tab)) - } icon: { - Image(fontAwesome: homeTabIcon(tab)) + Group { + if globalAppearance.largeScreenLayoutMode == .singleColumn && horizontalSizeClass == .regular { + SingleColumnFlareRoot( + tabs: items, + selectedTab: $selectedTab, + notificationCount: Int(notificationBadgePresenter.state.count), + navigationModel: navigationModel + ) + } else { + TabView(selection: $selectedTab) { + ForEach(items, id: \.name) { tab in + Router(backStack: navigationModel.binding(for: homeTabKey(tab))) { onNavigate in + homeTabRoute(tab).view(onNavigate: onNavigate, goBack: {}) + } + .tabItem { + Label { + Text(homeTabTitle(tab)) + } icon: { + Image(fontAwesome: homeTabIcon(tab)) + } + .adaptiveLabelStyle(globalAppearance.showBottomBarLabels) + } + .badge(homeTabRoute(tab) == .notification ? Int(notificationBadgePresenter.state.count) : 0) + .tag(homeTabKey(tab)) } - .adaptiveLabelStyle(globalAppearance.showBottomBarLabels) } - .badge(homeTabRoute(tab) == .notification ? Int(notificationBadgePresenter.state.count) : 0) - .tag(homeTabKey(tab)) + .modifier(TabBarDoubleTapModifier { + NotificationCenter.default.post(name: .tabDoubleTapped, object: selectedTab) + }) } } - .modifier(TabBarDoubleTapModifier { - NotificationCenter.default.post(name: .tabDoubleTapped, object: selectedTab) - }) .background(Color(.systemGroupedBackground)) .sheet(item: $reloginRoute) { route in NavigationStack { @@ -347,3 +371,217 @@ private enum SecondarySidebarStaticRoute: CaseIterable { } } } + +@MainActor +private final class HomeNavigationModel: ObservableObject { + @Published private var backStacks: [String: [Route]] = [:] + let navigator = RouterNavigator() + + func binding(for key: String) -> Binding<[Route]> { + Binding( + get: { self.backStacks[key] ?? [] }, + set: { self.backStacks[key] = $0 } + ) + } +} + +private struct SingleColumnFlareRoot: View { + let tabs: [HomeTabsPresenterStateHomeTabs] + @Binding var selectedTab: String? + let notificationCount: Int + @ObservedObject var navigationModel: HomeNavigationModel + @Environment(\.globalAppearance) private var globalAppearance + + private var activeTab: HomeTabsPresenterStateHomeTabs? { + tabs.first { homeTabKey($0) == selectedTab } ?? tabs.first + } + + var body: some View { + GeometryReader { proxy in + let showRightSidebar = proxy.size.width >= 900 + HStack(spacing: 0) { + VStack(spacing: 8) { + ForEach(tabs, id: \.name) { tab in + let key = homeTabKey(tab) + let selected = key == activeTab.map { homeTabKey($0) } + Button { + if selected { + NotificationCenter.default.post(name: .tabDoubleTapped, object: key) + } else { + selectedTab = key + } + } label: { + VStack(spacing: 4) { + ZStack(alignment: .topTrailing) { + Image(fontAwesome: homeTabIcon(tab)) + .font(.title3) + if homeTabRoute(tab) == .notification && notificationCount > 0 { + Text(notificationCount.formatted()) + .font(.caption2) + .foregroundStyle(.white) + .padding(.horizontal, 5) + .background(.red, in: Capsule()) + .offset(x: 12, y: -8) + } + } + if globalAppearance.showBottomBarLabels { + Text(homeTabTitle(tab)) + .font(.caption2) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(selected ? Color.accentColor : Color.secondary) + .background( + selected ? Color.accentColor.opacity(0.12) : Color.clear, + in: RoundedRectangle(cornerRadius: 12) + ) + .padding(.horizontal, 8) + } + Spacer(minLength: 0) + if !showRightSidebar { + Button { + navigationModel.navigator.navigate(.secondaryMenu) + } label: { + VStack(spacing: 4) { + Image(fontAwesome: .ellipsis) + .font(.title3) + if globalAppearance.showBottomBarLabels { + Text("more") + .font(.caption2) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .buttonStyle(.plain) + .foregroundStyle(Color.secondary) + .padding(.horizontal, 8) + } + } + .padding(.top, 12) + .frame(width: 80) + .background(.bar) + + Divider() + + HStack(spacing: 0) { + if let activeTab { + Router( + backStack: navigationModel.binding(for: homeTabKey(activeTab)), + navigator: navigationModel.navigator + ) { onNavigate in + homeTabRoute(activeTab).view(onNavigate: onNavigate, goBack: {}) + } + .environment(\.horizontalSizeClass, .compact) + .frame(maxWidth: 480, maxHeight: .infinity) + .frame(maxWidth: .infinity, alignment: .center) + } + + if showRightSidebar { + Divider() + SingleColumnSecondarySidebar(navigator: navigationModel.navigator) + .frame(width: 320) + } + } + } + } + .onAppear { + if !tabs.contains(where: { homeTabKey($0) == selectedTab }) { + selectedTab = tabs.first.map { homeTabKey($0) } + } + } + } +} + +private struct SingleColumnSecondarySidebar: View { + @ObservedObject var navigator: RouterNavigator + @StateObject private var secondaryTabsPresenter = KotlinPresenter(presenter: SecondaryTabsPresenter()) + @StateObject private var loggedInPresenter = KotlinPresenter(presenter: LoggedInPresenter()) + @StateObject private var aiAgentEnabledPresenter = KotlinPresenter(presenter: AiAgentEnabledPresenter()) + @State private var searchQuery = "" + + private var accounts: [SecondaryTabsPresenter.Item] { + guard case .success(let data) = onEnum(of: secondaryTabsPresenter.state.items) else { + return [] + } + return data.data.cast(SecondaryTabsPresenter.Item.self) + } + + private var searchAccount: AccountType { + accounts.first?.accountType ?? AccountType.Guest.shared + } + + var body: some View { + VStack(spacing: 0) { + TextField("search", text: $searchQuery) + .textFieldStyle(.roundedBorder) + .submitLabel(.search) + .onSubmit { + navigator.navigate(.search(searchAccount, searchQuery.trimmingCharacters(in: .whitespacesAndNewlines))) + } + .padding(16) + + List { + if case .success(let loggedIn) = onEnum(of: loggedInPresenter.state.isLoggedIn), + !loggedIn.data.boolValue { + Button { + navigator.navigate(.serviceSelect) + } label: { + Label("login_title", systemImage: "person.badge.plus") + } + } + + if !accounts.isEmpty { + Section("account_management_title") { + ForEach(Array(accounts.enumerated()), id: \.offset) { _, account in + DisclosureGroup { + ForEach(account.tabs, id: \.self) { tab in + if let route = route(for: tab) { + Button { + navigator.navigate(route) + } label: { + Label { + Text(tab.title.text) + } icon: { + Image(fontAwesome: tab.icon.fontAwesomeIcon) + } + } + .buttonStyle(.plain) + } + } + } label: { + StateView(state: account.user) { user in + UserCompatView(data: user) + } + } + } + } + } + + Section { + ForEach(SecondarySidebarStaticRoute.allCases.filter { route in + route != .agentHistory || aiAgentEnabledPresenter.state.enabled + }, id: \.self) { item in + Button { + navigator.navigate(item.route) + } label: { + Label { + Text(item.title) + } icon: { + Image(fontAwesome: item.icon) + } + } + .buttonStyle(.plain) + } + } + } + .listStyle(.sidebar) + } + .background(Color(.secondarySystemGroupedBackground)) + } +} diff --git a/appleApp/ios/UI/Route/Router.swift b/appleApp/ios/UI/Route/Router.swift index 24c12e6fe2..caa78eb0e0 100644 --- a/appleApp/ios/UI/Route/Router.swift +++ b/appleApp/ios/UI/Route/Router.swift @@ -1,22 +1,30 @@ import SwiftUI +import Combine import KotlinSharedUI import LazyPager -import Combine import FlareAppleCore import FlareAppleUI struct Router: View { @Environment(\.openURL) private var openURL @ViewBuilder let root: (@escaping (Route) -> Void) -> Root - @State private var backStack: [Route] = [] + @State private var ownedBackStack: [Route] = [] + private let externalBackStack: Binding<[Route]>? + private let navigator: RouterNavigator? @State private var sheet: Route? = nil @State private var cover: Route? = nil @State private var alertRoute: Route? = nil @StateObject private var deepLinkPresenter: KotlinPresenter @StateObject private var deepLinkHandler = DeepLinkHandler() - init(@ViewBuilder root: @escaping (@escaping (Route) -> Void) -> Root) { + init( + backStack: Binding<[Route]>? = nil, + navigator: RouterNavigator? = nil, + @ViewBuilder root: @escaping (@escaping (Route) -> Void) -> Root + ) { self.root = root + self.externalBackStack = backStack + self.navigator = navigator let handler = DeepLinkHandler() self._deepLinkHandler = .init(wrappedValue: handler) self._deepLinkPresenter = .init(wrappedValue: .init(presenter: DeepLinkPresenter(onRoute: { [weak handler] deeplinkRoute in @@ -27,16 +35,24 @@ struct Router: View { handler?.onLink?(link) }))) } + + private var backStack: Binding<[Route]> { + externalBackStack ?? $ownedBackStack + } + + private var navigationRequests: AnyPublisher { + navigator?.requests.eraseToAnyPublisher() ?? Empty().eraseToAnyPublisher() + } var body: some View { - NavigationStack(path: $backStack) { + NavigationStack(path: backStack) { root({ route in navigate(route: route) }) .navigationDestination(for: Route.self) { route in route.view( onNavigate: { route in navigate(route: route) }, - goBack: { backStack.removeLast() } + goBack: pop ) } } @@ -46,14 +62,14 @@ struct Router: View { NavigationStack { route.view( onNavigate: { route in navigate(route: route) }, - goBack: { backStack.removeLast() } + goBack: pop ) } } else { NavigationStack { route.view( onNavigate: { route in navigate(route: route) }, - goBack: { backStack.removeLast() } + goBack: pop ) .navigationDestination(for: Route.self) { destination in destination.view( @@ -68,7 +84,7 @@ struct Router: View { NavigationStack { route.view( onNavigate: { route in navigate(route: route) }, - goBack: { backStack.removeLast() } + goBack: pop ) } .background(ClearFullScreenBackground()) @@ -87,6 +103,9 @@ struct Router: View { let targetURL = url.openInFlareTargetURL ?? url deepLinkPresenter.state.handle(url: targetURL.absoluteString) } + .onReceive(navigationRequests) { route in + navigate(route: route) + } .onAppear { deepLinkHandler.onRoute = { route in navigate(route: route) @@ -106,12 +125,18 @@ struct Router: View { sheet = route } else if isFullScreenCover(route: route) { cover = route - } else if backStack.last != route { - backStack.append(route) + } else if backStack.wrappedValue.last != route { + backStack.wrappedValue.append(route) sheet = nil cover = nil } } + + private func pop() { + if !backStack.wrappedValue.isEmpty { + backStack.wrappedValue.removeLast() + } + } func isSheetRoute(route: Route) -> Bool { switch route { @@ -148,6 +173,15 @@ struct Router: View { } } +@MainActor +final class RouterNavigator: ObservableObject { + fileprivate let requests = PassthroughSubject() + + func navigate(_ route: Route) { + requests.send(route) + } +} + class DeepLinkHandler : ObservableObject { var onRoute: ((Route) -> Void)? var onLink: ((String) -> Void)? diff --git a/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml b/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml index 491ba9897b..63e2a3d556 100644 --- a/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml +++ b/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml @@ -286,6 +286,11 @@ 在底部导航栏和侧边栏显示文字标签 分栏模式 为支持的时间轴使用多列布局 + 大屏幕布局 + 选择 Flare 在大屏幕上如何使用空间 + 自动 + 多栏模式 + 单栏 显示操作按钮 在每条内容下方显示操作按钮 显示媒体 diff --git a/desktopApp/src/main/composeResources/values-zh-rTW/strings.xml b/desktopApp/src/main/composeResources/values-zh-rTW/strings.xml index f2fabd32ff..7c6cd24c38 100644 --- a/desktopApp/src/main/composeResources/values-zh-rTW/strings.xml +++ b/desktopApp/src/main/composeResources/values-zh-rTW/strings.xml @@ -230,6 +230,11 @@ 在底部導航和側邊欄顯示文字標籤 分欄模式 (Deck mode) 為支援的時間軸使用分欄佈局 + 大螢幕佈局 + 選擇 Flare 在大螢幕上如何使用空間 + 自動 + 多欄模式 + 單欄 顯示動作按鈕 在每則貼文下方顯示動作按鈕 顯示媒體 diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index 607745890c..5b1d1b67ee 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -340,6 +340,11 @@ Show text labels in bottom navigation and the side rail Deck mode Use a deck layout for supported timelines + Large-screen layout + Choose how Flare uses space on larger screens + Auto + Deck mode + Single column Show actions Show action buttons below each post Show media diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index d27410f3b0..5f5005bbf8 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -54,6 +55,7 @@ import compose.icons.fontawesomeicons.solid.House import compose.icons.fontawesomeicons.solid.MagnifyingGlass import compose.icons.fontawesomeicons.solid.Pen import compose.icons.fontawesomeicons.solid.UserPlus +import dev.dimension.flare.data.model.appearance.LargeScreenLayoutMode import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.FAIcon @@ -61,6 +63,8 @@ import dev.dimension.flare.ui.component.FlareScrollBar import dev.dimension.flare.ui.component.InAppNotificationComponent import dev.dimension.flare.ui.component.LocalGlobalAppearance import dev.dimension.flare.ui.component.RichText +import dev.dimension.flare.ui.component.platform.LocalWindowSizeClass +import dev.dimension.flare.ui.component.platform.WindowSizeClass import dev.dimension.flare.ui.component.toImageVector import dev.dimension.flare.ui.model.asText import dev.dimension.flare.ui.model.map @@ -77,6 +81,7 @@ import dev.dimension.flare.ui.presenter.home.LoggedInState import dev.dimension.flare.ui.presenter.home.SecondaryTabsPresenter import dev.dimension.flare.ui.presenter.home.UserState import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.presenter.settings.AiAgentEnabledPresenter import dev.dimension.flare.ui.route.Route import dev.dimension.flare.ui.route.Router import dev.dimension.flare.ui.route.TopLevelBackStack @@ -114,179 +119,184 @@ internal fun WindowScope.FlareApp(backButtonState: NavigationBackButtonState) { } } val currentRoute = state.topLevelBackStack.takeSuccess()?.currentRoute - val showNavigationLabels = LocalGlobalAppearance.current.showBottomBarLabels + val globalAppearance = LocalGlobalAppearance.current + val showNavigationLabels = globalAppearance.showBottomBarLabels - Row { - Column( - modifier = - Modifier - .background( - FluentTheme.colors.background.mica.base, - ).fillMaxHeight() - .width(72.dp) - .verticalScroll(rememberScrollState()) - .padding(top = LocalWindowPadding.current.calculateTopPadding()), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - state.isLoggedIn - .onSuccess { loggedIn -> - if (!loggedIn) { - Button( - onClick = { - state.navigate(Route.ServiceSelect) - }, - modifier = - Modifier - .padding(vertical = 4.dp) - .fillMaxWidth(), - ) { - Column( - modifier = - Modifier - .padding(vertical = 4.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - FontAwesomeIcons.Solid.UserPlus, - contentDescription = stringResource(Res.string.home_login), - modifier = Modifier.size(16.dp), - ) - Text(stringResource(Res.string.home_login), maxLines = 1) - } - } - } else { - FlyoutContainer( - flyout = { - val scrollableState = rememberScrollState() - FlareScrollBar( - state = scrollableState, + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val singleColumn = globalAppearance.largeScreenLayoutMode == LargeScreenLayoutMode.SingleColumn + val showRightSidebar = singleColumn && maxWidth >= 904.dp + Row(modifier = Modifier.fillMaxSize()) { + Column( + modifier = + Modifier + .background( + FluentTheme.colors.background.mica.base, + ).fillMaxHeight() + .width(72.dp) + .verticalScroll(rememberScrollState()) + .padding(top = LocalWindowPadding.current.calculateTopPadding()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (!showRightSidebar) { + state.isLoggedIn + .onSuccess { loggedIn -> + if (!loggedIn) { + Button( + onClick = { + state.navigate(Route.ServiceSelect) + }, + modifier = + Modifier + .padding(vertical = 4.dp) + .fillMaxWidth(), ) { Column( modifier = Modifier - .widthIn( - max = 320.dp, - ).heightIn( - max = 600.dp, - ).verticalScroll(scrollableState), + .padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), ) { - state.items.onSuccess { items -> - items.forEach { item -> - item.user.onSuccess { user -> - var isSubMenuExpanded by remember { - mutableStateOf( - false, - ) - } - Expander( - expanded = isSubMenuExpanded, - onExpandedChanged = { - isSubMenuExpanded = it - }, - heading = { - RichText( - text = user.name, - maxLines = 1, - ) - }, - caption = { - Text( - text = user.handle.canonical, - maxLines = 1, - ) - }, - icon = { - AvatarComponent( - data = user.avatar, - modifier = - Modifier - .aspectRatio(1f), - size = 24.dp, - ) - }, - ) { - item.tabs.forEach { shortcut -> - CardExpanderItem( - onClick = { - state.navigate(shortcut) - isFlyoutVisible = false + Icon( + FontAwesomeIcons.Solid.UserPlus, + contentDescription = stringResource(Res.string.home_login), + modifier = Modifier.size(16.dp), + ) + Text(stringResource(Res.string.home_login), maxLines = 1) + } + } + } else { + FlyoutContainer( + flyout = { + val scrollableState = rememberScrollState() + FlareScrollBar( + state = scrollableState, + ) { + Column( + modifier = + Modifier + .widthIn( + max = 320.dp, + ).heightIn( + max = 600.dp, + ).verticalScroll(scrollableState), + ) { + state.items.onSuccess { items -> + items.forEach { item -> + item.user.onSuccess { user -> + var isSubMenuExpanded by remember { + mutableStateOf( + false, + ) + } + Expander( + expanded = isSubMenuExpanded, + onExpandedChanged = { + isSubMenuExpanded = it }, heading = { - dev.dimension.flare.ui.component.Text( - shortcut.title.asText(), + RichText( + text = user.name, + maxLines = 1, + ) + }, + caption = { + Text( + text = user.handle.canonical, + maxLines = 1, ) }, icon = { - FAIcon( - imageVector = shortcut.icon.toImageVector(), - contentDescription = null, + AvatarComponent( + data = user.avatar, modifier = - Modifier.size( - 16.dp, - ), + Modifier + .aspectRatio(1f), + size = 24.dp, ) }, - ) + ) { + item.tabs.forEach { shortcut -> + CardExpanderItem( + onClick = { + state.navigate(shortcut) + isFlyoutVisible = false + }, + heading = { + dev.dimension.flare.ui.component.Text( + shortcut.title.asText(), + ) + }, + icon = { + FAIcon( + imageVector = shortcut.icon.toImageVector(), + contentDescription = null, + modifier = + Modifier.size( + 16.dp, + ), + ) + }, + ) + } + } } } } } } - } - } - }, - placement = FlyoutPlacement.EndAlignedTop, - adaptivePlacement = true, - ) { - state.user - .onSuccess { - Box( - modifier = - Modifier - .fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - AvatarComponent( - data = it.avatar, - modifier = - Modifier - .clickable { - isFlyoutVisible = !isFlyoutVisible - }.aspectRatio(1f), - ) - } - }.onLoading { - SubtleButton( - onClick = { - isFlyoutVisible = !isFlyoutVisible - }, - modifier = Modifier.fillMaxWidth(), - ) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Bars, - contentDescription = stringResource(Res.string.home_settings), + }, + placement = FlyoutPlacement.EndAlignedTop, + adaptivePlacement = true, + ) { + state.user + .onSuccess { + Box( + modifier = + Modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + AvatarComponent( + data = it.avatar, + modifier = + Modifier + .clickable { + isFlyoutVisible = !isFlyoutVisible + }.aspectRatio(1f), + ) + } + }.onLoading { + SubtleButton( + onClick = { + isFlyoutVisible = !isFlyoutVisible + }, + modifier = Modifier.fillMaxWidth(), + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Bars, + contentDescription = stringResource(Res.string.home_settings), // modifier = Modifier.size(16.dp), - ) - } - }.onError { - SubtleButton( - onClick = { - isFlyoutVisible = !isFlyoutVisible - }, - modifier = Modifier.fillMaxWidth(), - ) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Bars, - contentDescription = stringResource(Res.string.home_settings), + ) + } + }.onError { + SubtleButton( + onClick = { + isFlyoutVisible = !isFlyoutVisible + }, + modifier = Modifier.fillMaxWidth(), + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Bars, + contentDescription = stringResource(Res.string.home_settings), // modifier = Modifier.size(16.dp), - ) - } + ) + } + } } - } - Spacer(modifier = Modifier.height(8.dp)) - } + Spacer(modifier = Modifier.height(8.dp)) + } // SubtleButton( // onClick = { // // state.navigate(Route.MeRoute(AccountType.Specific(user.key))) @@ -345,11 +355,69 @@ internal fun WindowScope.FlareApp(backButtonState: NavigationBackButtonState) { // modifier = Modifier.size(16.dp), // ) // } + } } - @Composable - fun buildMenuItem(tab: HomeTabsPresenter.State.HomeTabs) { - val selected = currentRoute == getRoute(tab) + @Composable + fun buildMenuItem(tab: HomeTabsPresenter.State.HomeTabs) { + val selected = currentRoute == getRoute(tab) + val color by animateColorAsState( + targetValue = + if (selected) { + FluentTheme.colors.fillAccent.secondary + } else { + FluentTheme.colors.system.neutral + }, + ) + NavigationItem( + onClick = { + if (selected) { + state.scrollToTopRegistry.scrollToTop() + } else { + state.navigate(getRoute(tab)) + } + }, + icon = { + FAIcon( + imageVector = tab.icon, + contentDescription = stringResource(tab.title), + tint = color, + modifier = + Modifier + .size(24.dp), + ) + }, + text = + if (showNavigationLabels) { + { + Text( + stringResource(tab.title), + maxLines = 1, + color = color, + style = FluentTheme.typography.caption, + ) + } + } else { + null + }, + badge = + if (tab == HomeTabsPresenter.State.HomeTabs.Notifications) { + if (state.notificationState.count > 0) { + { + Text(state.notificationState.count.toString()) + } + } else { + null + } + } else { + null + }, + ) + } + tabs.forEach { tab -> + buildMenuItem(tab) + } + val selected = currentRoute == Route.Settings val color by animateColorAsState( targetValue = if (selected) { @@ -358,161 +426,152 @@ internal fun WindowScope.FlareApp(backButtonState: NavigationBackButtonState) { FluentTheme.colors.system.neutral }, ) - NavigationItem( - onClick = { - if (selected) { - state.scrollToTopRegistry.scrollToTop() - } else { - state.navigate(getRoute(tab)) - } - }, - icon = { - FAIcon( - imageVector = tab.icon, - contentDescription = stringResource(tab.title), - tint = color, - modifier = - Modifier - .size(24.dp), + if (state.canComposeState.takeSuccess() == true) { + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = + Modifier + .shadow(4.dp, CircleShape) + .background( + FluentTheme.colors.fillAccent.secondary, + CircleShape, + ).fillMaxWidth(0.66f) + .aspectRatio(1f) + .clip(CircleShape) + .clickable { + state.navigate( + Route.Compose.New, + ) + }, + contentAlignment = Alignment.Center, + ) { + Icon( + FontAwesomeIcons.Solid.Pen, + contentDescription = stringResource(Res.string.home_compose), + modifier = Modifier.size(16.dp), + tint = FluentTheme.colors.text.onAccent.primary, ) - }, - text = - if (showNavigationLabels) { - { - Text( - stringResource(tab.title), - maxLines = 1, - color = color, - style = FluentTheme.typography.caption, - ) - } - } else { - null + } + } + Spacer(modifier = Modifier.weight(1f)) + if (!showRightSidebar) { + NavigationItem( + icon = { + Icon( + FontAwesomeIcons.Solid.Gear, + contentDescription = stringResource(Res.string.home_settings), + modifier = Modifier.size(24.dp), + tint = color, + ) }, - badge = - if (tab == HomeTabsPresenter.State.HomeTabs.Notifications) { - if (state.notificationState.count > 0) { + text = + if (showNavigationLabels) { { - Text(state.notificationState.count.toString()) + Text( + stringResource(Res.string.home_settings), + maxLines = 1, + style = FluentTheme.typography.caption, + color = color, + ) } } else { null - } - } else { - null - }, - ) - } - tabs.forEach { tab -> - buildMenuItem(tab) - } - val selected = currentRoute == Route.Settings - val color by animateColorAsState( - targetValue = - if (selected) { - FluentTheme.colors.fillAccent.secondary - } else { - FluentTheme.colors.system.neutral - }, - ) - if (state.canComposeState.takeSuccess() == true) { - Spacer(modifier = Modifier.height(8.dp)) - Box( - modifier = - Modifier - .shadow(4.dp, CircleShape) - .background( - FluentTheme.colors.fillAccent.secondary, - CircleShape, - ).fillMaxWidth(0.66f) - .aspectRatio(1f) - .clip(CircleShape) - .clickable { - state.navigate( - Route.Compose.New, - ) }, - contentAlignment = Alignment.Center, - ) { - Icon( - FontAwesomeIcons.Solid.Pen, - contentDescription = stringResource(Res.string.home_compose), - modifier = Modifier.size(16.dp), - tint = FluentTheme.colors.text.onAccent.primary, + onClick = { + state.navigate(Route.Settings) + }, ) } } - Spacer(modifier = Modifier.weight(1f)) - NavigationItem( - icon = { - Icon( - FontAwesomeIcons.Solid.Gear, - contentDescription = stringResource(Res.string.home_settings), - modifier = Modifier.size(24.dp), - tint = color, - ) - }, - text = - if (showNavigationLabels) { - { - Text( - stringResource(Res.string.home_settings), - maxLines = 1, - style = FluentTheme.typography.caption, - color = color, - ) - } - } else { - null - }, - onClick = { - state.navigate(Route.Settings) - }, - ) - } // CommandBarSeparator() - CompositionLocalProvider( - LocalUriHandler provides - remember { - object : UriHandler { - override fun openUri(uri: String) { - state.deeplinkPresenter.handle(uri) + CompositionLocalProvider( + LocalUriHandler provides + remember { + object : UriHandler { + override fun openUri(uri: String) { + state.deeplinkPresenter.handle(uri) + } } - } - }, - LocalScrollToTopRegistry provides state.scrollToTopRegistry, - ) { - Layer( - modifier = Modifier.fillMaxSize(), - color = FluentTheme.colors.background.mica.base, - shape = RoundedCornerShape(0), - border = null, + }, + LocalScrollToTopRegistry provides state.scrollToTopRegistry, ) { - Box { - Router( - backStack = + Layer( + modifier = Modifier.fillMaxSize(), + color = FluentTheme.colors.background.mica.base, + shape = RoundedCornerShape(0), + border = null, + ) { + Box { + val backStack = state.topLevelBackStack.takeSuccess()?.stack - ?: persistentListOf(), - navigate = { route -> state.navigate(route) }, - replace = { route -> state.replace(route) }, - onBack = { state.goBack() }, - ) - Spacer( - modifier = - Modifier - .fillMaxHeight() - .width(1.dp) - .background(FluentTheme.colors.stroke.divider.default) - .align(Alignment.CenterStart), - ) - InAppNotificationComponent( - modifier = - Modifier - .align(Alignment.TopCenter), - onRelogin = { - state.navigate(Route.Relogin(it)) - }, - ) + ?: persistentListOf() + if (singleColumn) { + Row(modifier = Modifier.fillMaxSize()) { + Box( + modifier = + Modifier + .weight(1f) + .fillMaxHeight(), + contentAlignment = Alignment.TopCenter, + ) { + CompositionLocalProvider( + LocalWindowSizeClass provides WindowSizeClass.Compact, + ) { + Router( + backStack = backStack, + navigate = state::navigate, + replace = state::replace, + onBack = state::goBack, + modifier = + Modifier + .width(480.dp) + .fillMaxHeight(), + singlePane = true, + ) + } + } + if (showRightSidebar) { + Spacer( + modifier = + Modifier + .fillMaxHeight() + .width(1.dp) + .background(FluentTheme.colors.stroke.divider.default), + ) + HomeSecondarySidebar( + secondaryTabs = state.items, + isLoggedIn = state.isLoggedIn.takeSuccess(), + aiAgentEnabled = state.aiAgentEnabled, + navigate = state::navigate, + modifier = Modifier.width(336.dp), + ) + } + } + } else { + Router( + backStack = backStack, + navigate = state::navigate, + replace = state::replace, + onBack = state::goBack, + ) + } + Spacer( + modifier = + Modifier + .fillMaxHeight() + .width(1.dp) + .background(FluentTheme.colors.stroke.divider.default) + .align(Alignment.CenterStart), + ) + InAppNotificationComponent( + modifier = + Modifier + .align(Alignment.TopCenter), + onRelogin = { + state.navigate(Route.Relogin(it)) + }, + ) + } } } } @@ -520,7 +579,7 @@ internal fun WindowScope.FlareApp(backButtonState: NavigationBackButtonState) { } } -private fun getDirection(data: SecondaryTabsPresenter.Tab): Route? = +internal fun getDirection(data: SecondaryTabsPresenter.Tab): Route? = when (val target = data.destination) { is SecondaryTabsPresenter.Destination.Route -> Route.from(target.route) is SecondaryTabsPresenter.Destination.Timeline -> Route.Timeline(target.tabItem) @@ -623,6 +682,7 @@ private fun presenter(uriHandler: UriHandler) = val canComposeState = remember { CanComposePresenter() }.invoke() val tabState = remember { HomeTabsPresenter() }.invoke() val allNotificationState = remember { AllNotificationBadgePresenter() }.invoke() + val aiAgentEnabledState = remember { AiAgentEnabledPresenter() }.invoke() val scrollToTopRegistry = remember { ScrollToTopRegistry() @@ -669,6 +729,7 @@ private fun presenter(uriHandler: UriHandler) = val scrollToTopRegistry = scrollToTopRegistry val deeplinkPresenter = deeplinkPresenter val topLevelBackStack = topLevelBackStack + val aiAgentEnabled = aiAgentEnabledState.enabled fun navigate(route: Route) { when (route) { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/HomeSecondarySidebar.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/HomeSecondarySidebar.kt new file mode 100644 index 0000000000..b09ff2bbfe --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/HomeSecondarySidebar.kt @@ -0,0 +1,187 @@ +package dev.dimension.flare + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.ClockRotateLeft +import compose.icons.fontawesomeicons.solid.Gear +import compose.icons.fontawesomeicons.solid.MagnifyingGlass +import compose.icons.fontawesomeicons.solid.PenToSquare +import compose.icons.fontawesomeicons.solid.Robot +import compose.icons.fontawesomeicons.solid.SquareRss +import compose.icons.fontawesomeicons.solid.UserPlus +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.ui.component.AvatarComponent +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.FlareScrollBar +import dev.dimension.flare.ui.component.RichText +import dev.dimension.flare.ui.component.toImageVector +import dev.dimension.flare.ui.model.UiState +import dev.dimension.flare.ui.model.asText +import dev.dimension.flare.ui.model.takeSuccess +import dev.dimension.flare.ui.presenter.home.SecondaryTabsPresenter +import dev.dimension.flare.ui.route.Route +import io.github.composefluent.component.CardExpanderItem +import io.github.composefluent.component.Expander +import io.github.composefluent.component.SubtleButton +import io.github.composefluent.component.Text +import io.github.composefluent.component.TextField +import kotlinx.collections.immutable.ImmutableList +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun HomeSecondarySidebar( + secondaryTabs: UiState>, + isLoggedIn: Boolean?, + aiAgentEnabled: Boolean, + navigate: (Route) -> Unit, + modifier: Modifier = Modifier, +) { + val accounts = secondaryTabs.takeSuccess().orEmpty() + val searchAccount = accounts.firstOrNull()?.accountType ?: AccountType.Guest + val searchState = rememberTextFieldState() + val submitSearch = { + navigate( + Route.Search( + accountType = searchAccount, + keyword = searchState.text.toString().trim(), + ), + ) + } + val scrollState = rememberScrollState() + + FlareScrollBar(scrollState) { + Column( + modifier = + modifier + .fillMaxHeight() + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextField( + state = searchState, + modifier = Modifier.fillMaxWidth(), + lineLimits = TextFieldLineLimits.SingleLine, + onKeyboardAction = { submitSearch() }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + placeholder = { Text(stringResource(Res.string.emoji_picker_search)) }, + trailing = { + SubtleButton( + onClick = submitSearch, + iconOnly = true, + ) { + FAIcon( + FontAwesomeIcons.Solid.MagnifyingGlass, + contentDescription = stringResource(Res.string.emoji_picker_search), + ) + } + }, + ) + + if (isLoggedIn == false) { + SidebarItem( + label = stringResource(Res.string.home_login), + icon = FontAwesomeIcons.Solid.UserPlus, + onClick = { navigate(Route.ServiceSelect) }, + ) + } + + accounts.forEach { account -> + var expanded by remember(account.accountType) { mutableStateOf(true) } + account.user.takeSuccess()?.let { user -> + Expander( + expanded = expanded, + onExpandedChanged = { expanded = it }, + heading = { + RichText(text = user.name, maxLines = 1) + }, + caption = { + Text(text = user.handle.canonical, maxLines = 1) + }, + icon = { + AvatarComponent(data = user.avatar, size = 24.dp) + }, + ) { + account.tabs.forEach { shortcut -> + val route = getDirection(shortcut) ?: return@forEach + CardExpanderItem( + onClick = { navigate(route) }, + heading = { + dev.dimension.flare.ui.component + .Text(shortcut.title.asText()) + }, + icon = { + FAIcon( + imageVector = shortcut.icon.toImageVector(), + contentDescription = null, + ) + }, + ) + } + } + } + } + + SidebarItem( + label = stringResource(Res.string.settings_draft_box_title), + icon = FontAwesomeIcons.Solid.PenToSquare, + onClick = { navigate(Route.DraftBox) }, + ) + SidebarItem( + label = stringResource(Res.string.settings_rss_management_title), + icon = FontAwesomeIcons.Solid.SquareRss, + onClick = { navigate(Route.RssList) }, + ) + SidebarItem( + label = stringResource(Res.string.settings_local_history_title), + icon = FontAwesomeIcons.Solid.ClockRotateLeft, + onClick = { navigate(Route.LocalCache) }, + ) + if (aiAgentEnabled) { + SidebarItem( + label = stringResource(Res.string.settings_agent_history_title), + icon = FontAwesomeIcons.Solid.Robot, + onClick = { navigate(Route.AgentHistory) }, + ) + } + SidebarItem( + label = stringResource(Res.string.home_settings), + icon = FontAwesomeIcons.Solid.Gear, + onClick = { navigate(Route.Settings) }, + ) + } + } +} + +@Composable +private fun SidebarItem( + label: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + onClick: () -> Unit, +) { + CardExpanderItem( + onClick = onClick, + heading = { Text(label) }, + icon = { + FAIcon(imageVector = icon, contentDescription = label) + }, + ) +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index 8eed89c224..960686e1ad 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -124,6 +124,7 @@ internal fun Router( onBack: () -> Unit, enableDeepLinkHandler: Boolean = true, modifier: Modifier = Modifier, + singlePane: Boolean = false, ) { val listDetailStrategy = rememberListDetailSceneStrategy() @@ -148,12 +149,14 @@ internal fun Router( NavDisplay( modifier = modifier, sceneStrategies = - remember(listDetailStrategy) { - listOf( - FluentDialogSceneStrategy(), - WindowSceneStrategy(), - listDetailStrategy, - ) + remember(listDetailStrategy, singlePane) { + buildList { + add(FluentDialogSceneStrategy()) + add(WindowSceneStrategy()) + if (!singlePane) { + add(listDetailStrategy) + } + } }, entryDecorators = listOf( diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index 823db3df71..c11f313e9b 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -67,6 +67,7 @@ import dev.dimension.flare.data.model.TimelineMediaLayout import dev.dimension.flare.data.model.VideoAutoplay import dev.dimension.flare.data.model.appearance.AppearanceKey import dev.dimension.flare.data.model.appearance.AppearanceKeys +import dev.dimension.flare.data.model.appearance.LargeScreenLayoutMode import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.deeplink_account_selection_browser import dev.dimension.flare.delete @@ -147,8 +148,6 @@ import dev.dimension.flare.settings_appearance_avatar_shape_round import dev.dimension.flare.settings_appearance_avatar_shape_square import dev.dimension.flare.settings_appearance_compat_link_previews import dev.dimension.flare.settings_appearance_compat_link_previews_description -import dev.dimension.flare.settings_appearance_deck_mode -import dev.dimension.flare.settings_appearance_deck_mode_description import dev.dimension.flare.settings_appearance_display_group_subtitle import dev.dimension.flare.settings_appearance_display_group_title import dev.dimension.flare.settings_appearance_expand_content_warning @@ -157,6 +156,11 @@ import dev.dimension.flare.settings_appearance_expand_media import dev.dimension.flare.settings_appearance_expand_media_description import dev.dimension.flare.settings_appearance_full_width_post import dev.dimension.flare.settings_appearance_full_width_post_description +import dev.dimension.flare.settings_appearance_large_screen_layout +import dev.dimension.flare.settings_appearance_large_screen_layout_auto +import dev.dimension.flare.settings_appearance_large_screen_layout_deck +import dev.dimension.flare.settings_appearance_large_screen_layout_description +import dev.dimension.flare.settings_appearance_large_screen_layout_single_column import dev.dimension.flare.settings_appearance_layout_group_subtitle import dev.dimension.flare.settings_appearance_layout_group_title import dev.dimension.flare.settings_appearance_limit_media_grid_to_nine @@ -827,18 +831,47 @@ internal fun SettingsScreen( ExpanderItemSeparator() ExpanderItem( heading = { - Text(stringResource(Res.string.settings_appearance_deck_mode)) + Text(stringResource(Res.string.settings_appearance_large_screen_layout)) }, caption = { - Text(stringResource(Res.string.settings_appearance_deck_mode_description)) + Text(stringResource(Res.string.settings_appearance_large_screen_layout_description)) }, trailing = { - Switcher( - checked = LocalGlobalAppearance.current.deckMode, - { - state.appearanceState.update(AppearanceKeys.DeckMode, it) + val items = + remember { + persistentMapOf( + LargeScreenLayoutMode.Auto to + Res.string.settings_appearance_large_screen_layout_auto, + LargeScreenLayoutMode.Deck to + Res.string.settings_appearance_large_screen_layout_deck, + LargeScreenLayoutMode.SingleColumn to + Res.string.settings_appearance_large_screen_layout_single_column, + ) + } + MenuFlyoutContainer( + flyout = { + items.forEach { (mode, label) -> + MenuFlyoutItem( + onClick = { + state.appearanceState.update(AppearanceKeys.LargeScreenLayout, mode) + isFlyoutVisible = false + }, + text = { Text(stringResource(label)) }, + ) + } }, - textBefore = true, + content = { + DropDownButton( + onClick = { isFlyoutVisible = !isFlyoutVisible }, + content = { + items[LocalGlobalAppearance.current.largeScreenLayoutMode]?.let { label -> + Text(stringResource(label)) + } + }, + ) + }, + adaptivePlacement = true, + placement = FlyoutPlacement.BottomAlignedEnd, ) }, ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/appearance/AppearanceKeys.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/appearance/AppearanceKeys.kt index 235e97954c..a333e75133 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/appearance/AppearanceKeys.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/appearance/AppearanceKeys.kt @@ -53,6 +53,12 @@ public object AppearanceKeys { public object DeckMode : Global("app.deck_mode", false, Boolean.serializer()) + public object LargeScreenLayout : Global( + "app.large_screen_layout_mode", + LargeScreenLayoutMode.Auto, + LargeScreenLayoutMode.serializer(), + ) + public object ShowMedia : PerTimeline("timeline.show_media", true, Boolean.serializer()) public object ShowSensitiveContent : PerTimeline("timeline.show_sensitive_content", false, Boolean.serializer()) @@ -130,6 +136,7 @@ public object AppearanceKeys { ShowComposeInHomeTimeline, ShowBottomBarLabels, DeckMode, + LargeScreenLayout, ShowMedia, ShowSensitiveContent, ExpandContentWarning, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/appearance/AppearanceModels.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/appearance/AppearanceModels.kt index 3c03a8fc89..b73e09571f 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/appearance/AppearanceModels.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/appearance/AppearanceModels.kt @@ -10,6 +10,15 @@ import dev.dimension.flare.data.model.Theme import dev.dimension.flare.data.model.TimelineDisplayMode import dev.dimension.flare.data.model.TimelineMediaLayout import dev.dimension.flare.data.model.VideoAutoplay +import dev.dimension.flare.web.shared.WebIgnore +import kotlinx.serialization.Serializable + +@Serializable +public enum class LargeScreenLayoutMode { + Auto, + Deck, + SingleColumn, +} @Immutable public data class GlobalAppearance( @@ -24,8 +33,12 @@ public data class GlobalAppearance( val inAppBrowser: Boolean = AppearanceKeys.InAppBrowser.default, val showComposeInHomeTimeline: Boolean = AppearanceKeys.ShowComposeInHomeTimeline.default, val showBottomBarLabels: Boolean = AppearanceKeys.ShowBottomBarLabels.default, - val deckMode: Boolean = AppearanceKeys.DeckMode.default, + @WebIgnore + val largeScreenLayoutMode: LargeScreenLayoutMode = AppearanceKeys.LargeScreenLayout.default, ) { + public val deckMode: Boolean + get() = largeScreenLayoutMode == LargeScreenLayoutMode.Deck + public companion object { public val Default: GlobalAppearance = GlobalAppearance() } @@ -80,7 +93,14 @@ public fun AppearancePatch.toGlobalAppearance(): GlobalAppearance = inAppBrowser = get(AppearanceKeys.InAppBrowser), showComposeInHomeTimeline = get(AppearanceKeys.ShowComposeInHomeTimeline), showBottomBarLabels = get(AppearanceKeys.ShowBottomBarLabels), - deckMode = get(AppearanceKeys.DeckMode), + largeScreenLayoutMode = + if (contains(AppearanceKeys.LargeScreenLayout)) { + get(AppearanceKeys.LargeScreenLayout) + } else if (get(AppearanceKeys.DeckMode)) { + LargeScreenLayoutMode.Deck + } else { + LargeScreenLayoutMode.Auto + }, ) public fun AppearancePatch.toTimelineAppearance(): TimelineAppearance = toTimelineAppearance(override = null) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/SettingsPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/SettingsPresenter.kt index c86b95ad59..1a1a83cc8f 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/SettingsPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/SettingsPresenter.kt @@ -15,6 +15,7 @@ import dev.dimension.flare.data.model.VideoAutoplay import dev.dimension.flare.data.model.appearance.AppearanceKey import dev.dimension.flare.data.model.appearance.AppearanceKeys import dev.dimension.flare.data.model.appearance.AppearancePatch +import dev.dimension.flare.data.model.appearance.LargeScreenLayoutMode import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.di.koinInject import dev.dimension.flare.ui.model.UiState @@ -96,7 +97,21 @@ public class SettingsPresenter : PresenterBase() { override fun updateShowBottomBarLabels(value: Boolean) = update(AppearanceKeys.ShowBottomBarLabels, value) - override fun updateDeckMode(value: Boolean) = update(AppearanceKeys.DeckMode, value) + override fun updateDeckMode(value: Boolean) = + updateLargeScreenLayoutMode( + if (value) LargeScreenLayoutMode.Deck else LargeScreenLayoutMode.Auto, + ) + + override fun updateLargeScreenLayoutMode(value: LargeScreenLayoutMode) { + scope.launch { + withContext(Dispatchers.Main) { + repository.updateAppearance { + set(AppearanceKeys.LargeScreenLayout, value) + .set(AppearanceKeys.DeckMode, value == LargeScreenLayoutMode.Deck) + } + } + } + } override fun updateVideoAutoplay(value: VideoAutoplay) = update(AppearanceKeys.VideoAutoplay, value) @@ -182,6 +197,9 @@ public class SettingsPresenter : PresenterBase() { public fun updateDeckMode(value: Boolean) + @WebIgnore + public fun updateLargeScreenLayoutMode(value: LargeScreenLayoutMode) + public fun updateVideoAutoplay(value: VideoAutoplay) public fun updateTimelineDisplayMode(value: TimelineDisplayMode) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/SecondaryTabsPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/SecondaryTabsPresenter.kt index f7d522b0e0..bd4c08dc2d 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/SecondaryTabsPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/SecondaryTabsPresenter.kt @@ -41,6 +41,8 @@ public class SecondaryTabsPresenter : PresenterBase, public val tabs: ImmutableList, ) @@ -93,6 +95,7 @@ public class SecondaryTabsPresenter : PresenterBase userState.takeSuccess()?.let { user -> Item( + accountType = AccountType.Specific(service.accountKey), user = userState, tabs = ( diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/model/appearance/AppearancePatchTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/model/appearance/AppearancePatchTest.kt index 8681c29d0a..a66eec9cab 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/model/appearance/AppearancePatchTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/model/appearance/AppearancePatchTest.kt @@ -66,7 +66,7 @@ class AppearancePatchTest { GlobalAppearance( theme = Theme.DARK, showBottomBarLabels = false, - deckMode = true, + largeScreenLayoutMode = LargeScreenLayoutMode.Deck, ), patch.toGlobalAppearance(), ) @@ -187,6 +187,7 @@ class AppearancePatchTest { setOf( AppearanceKeys.ShowBottomBarLabels, AppearanceKeys.DeckMode, + AppearanceKeys.LargeScreenLayout, AppearanceKeys.ExpandContentWarning, AppearanceKeys.MediaLayout, AppearanceKeys.LimitMediaGridToNine, @@ -245,6 +246,7 @@ class AppearancePatchTest { .set(AppearanceKeys.Theme, Theme.LIGHT) .set(AppearanceKeys.ShowBottomBarLabels, false) .set(AppearanceKeys.DeckMode, true) + .set(AppearanceKeys.LargeScreenLayout, LargeScreenLayoutMode.SingleColumn) .set(AppearanceKeys.ShowMedia, false) .set(AppearanceKeys.ExpandContentWarning, true) .set(AppearanceKeys.LimitMediaGridToNine, false) @@ -264,6 +266,29 @@ class AppearancePatchTest { assertEquals(patch, patch.toBag().toPatch()) } + @Test + fun legacyDeckModeMapsToLargeScreenLayoutMode() { + assertEquals( + LargeScreenLayoutMode.Deck, + AppearancePatch.EMPTY + .set(AppearanceKeys.DeckMode, true) + .toGlobalAppearance() + .largeScreenLayoutMode, + ) + } + + @Test + fun explicitLargeScreenLayoutModeOverridesLegacyDeckMode() { + val appearance = + AppearancePatch.EMPTY + .set(AppearanceKeys.DeckMode, true) + .set(AppearanceKeys.LargeScreenLayout, LargeScreenLayoutMode.SingleColumn) + .toGlobalAppearance() + + assertEquals(LargeScreenLayoutMode.SingleColumn, appearance.largeScreenLayoutMode) + assertFalse(appearance.deckMode) + } + @Test fun unknownEntriesAreIgnored() { val bag = From 265ef695055a02a0271d8894846e48e569b3893c Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 1 Sep 2026 13:34:25 +0800 Subject: [PATCH 02/13] fix: remove single-column width limit --- .../java/dev/dimension/flare/ui/screen/home/HomeScreen.kt | 5 +---- appleApp/ios/UI/FlareRoot.swift | 3 +-- desktopApp/src/main/kotlin/dev/dimension/flare/App.kt | 5 +---- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt index 4079cd7de9..b0e04cd257 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt @@ -545,10 +545,7 @@ internal fun HomeScreen(afterInit: () -> Unit) { openDrawer = state::openDrawer, navigate = state::navigate, onBack = state::goBack, - modifier = - Modifier - .width(480.dp) - .fillMaxHeight(), + modifier = Modifier.fillMaxSize(), singlePane = true, ) } diff --git a/appleApp/ios/UI/FlareRoot.swift b/appleApp/ios/UI/FlareRoot.swift index 0dcffe6c88..7a60179ae3 100644 --- a/appleApp/ios/UI/FlareRoot.swift +++ b/appleApp/ios/UI/FlareRoot.swift @@ -478,8 +478,7 @@ private struct SingleColumnFlareRoot: View { homeTabRoute(activeTab).view(onNavigate: onNavigate, goBack: {}) } .environment(\.horizontalSizeClass, .compact) - .frame(maxWidth: 480, maxHeight: .infinity) - .frame(maxWidth: .infinity, alignment: .center) + .frame(maxWidth: .infinity, maxHeight: .infinity) } if showRightSidebar { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index 5f5005bbf8..3c64de5887 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -522,10 +522,7 @@ internal fun WindowScope.FlareApp(backButtonState: NavigationBackButtonState) { navigate = state::navigate, replace = state::replace, onBack = state::goBack, - modifier = - Modifier - .width(480.dp) - .fillMaxHeight(), + modifier = Modifier.fillMaxSize(), singlePane = true, ) } From 340342c05c3b45590a04d2a6f1382533d8d6abb5 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 1 Sep 2026 13:47:59 +0800 Subject: [PATCH 03/13] fix: make secondary routes top level --- .../flare/ui/screen/home/HomeScreen.kt | 18 +++---- .../ui/component/TopLevelBackStackTest.kt | 20 ++++++++ appleApp/ios/UI/FlareRoot.swift | 48 ++++++++++++------- appleApp/ios/UI/Route/Route.swift | 5 +- .../ios/UI/Screen/HomeTimelineScreen.swift | 48 ++++++++++--------- .../main/kotlin/dev/dimension/flare/App.kt | 9 +++- .../flare/ui/route/TopLevelBackStack.kt | 31 ++++++++---- 7 files changed, 119 insertions(+), 60 deletions(-) create mode 100644 app/src/test/java/dev/dimension/flare/ui/component/TopLevelBackStackTest.kt diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt index b0e04cd257..731bbbe92f 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt @@ -141,9 +141,9 @@ internal fun HomeScreen(afterInit: () -> Unit) { val singleColumn = globalAppearance.largeScreenLayoutMode == LargeScreenLayoutMode.SingleColumn && layoutType != NavigationSuiteType.NavigationBar + val hasRightSidebar = singleColumn && maxWidth >= 1024.dp val showRightSidebar = - singleColumn && - maxWidth >= 1024.dp && + hasRightSidebar && state.wideNavigationRailState.currentValue == WideNavigationRailValue.Collapsed NavigationSuiteScaffold2( wideNavigationRailState = state.wideNavigationRailState, @@ -294,7 +294,7 @@ internal fun HomeScreen(afterInit: () -> Unit) { } }, secondaryItems = { - if (layoutType != NavigationSuiteType.NavigationBar && !showRightSidebar) { + if (layoutType != NavigationSuiteType.NavigationBar && !hasRightSidebar) { item( selected = currentRoute is Route.DraftBox, onClick = { @@ -358,7 +358,7 @@ internal fun HomeScreen(afterInit: () -> Unit) { ) } } - if (!showRightSidebar) { + if (!hasRightSidebar) { state.secondaryTabsState.onSuccess { secondaryTabs -> secondaryTabs.forEach { item -> expandableItem( @@ -489,7 +489,7 @@ internal fun HomeScreen(afterInit: () -> Unit) { ) } } - if (!showRightSidebar) { + if (!hasRightSidebar) { item( selected = currentRoute is Route.Settings.Main, onClick = { @@ -557,9 +557,9 @@ internal fun HomeScreen(afterInit: () -> Unit) { isLoggedIn = state.loggedInState.takeSuccess(), aiAgentEnabled = state.aiAgentEnabled, currentRoute = - state.topLevelBackStack.takeSuccess()?.currentKey + state.topLevelBackStack.takeSuccess()?.topLevelKey ?: currentRoute, - navigate = state::navigateSecondary, + navigate = state::navigateTopLevel, modifier = Modifier.width(336.dp), ) } @@ -722,8 +722,8 @@ private fun presenter(uriHandler: UriHandler) = ) } - fun navigateSecondary(route: Route) { - topLevelBackStack.takeSuccess()?.add(route) + fun navigateTopLevel(route: Route) { + topLevelBackStack.takeSuccess()?.addTopLevel(route) scope.launch { wideNavigationRailState.collapse() } diff --git a/app/src/test/java/dev/dimension/flare/ui/component/TopLevelBackStackTest.kt b/app/src/test/java/dev/dimension/flare/ui/component/TopLevelBackStackTest.kt new file mode 100644 index 0000000000..b962284dbc --- /dev/null +++ b/app/src/test/java/dev/dimension/flare/ui/component/TopLevelBackStackTest.kt @@ -0,0 +1,20 @@ +package dev.dimension.flare.ui.component + +import dev.dimension.flare.ui.route.Route +import org.junit.Assert.assertEquals +import org.junit.Test + +class TopLevelBackStackTest { + @Test + fun secondaryRouteKeepsItsOwnTopLevelStack() { + val backStack = TopLevelBackStack(Route.Home) + + backStack.addTopLevel(Route.Settings.Main) + backStack.add(Route.Settings.AppearanceLayout) + backStack.addTopLevel(Route.DraftBox) + backStack.addTopLevel(Route.Settings.Main) + + assertEquals(Route.Settings.Main, backStack.topLevelKey) + assertEquals(Route.Settings.AppearanceLayout, backStack.currentKey) + } +} diff --git a/appleApp/ios/UI/FlareRoot.swift b/appleApp/ios/UI/FlareRoot.swift index 7a60179ae3..d201df2fc8 100644 --- a/appleApp/ios/UI/FlareRoot.swift +++ b/appleApp/ios/UI/FlareRoot.swift @@ -39,7 +39,7 @@ struct FlareRoot: View { TabView(selection: $selectedTab) { ForEach(items, id: \.name) { tab in Tab(value: homeTabKey(tab), role: homeTabRoute(tab) == .discover ? .some(.search) : .none) { - Router(backStack: navigationModel.binding(for: homeTabKey(tab))) { onNavigate in + Router(backStack: navigationModel.binding(for: homeTabRoute(tab))) { onNavigate in homeTabRoute(tab).view(onNavigate: onNavigate, goBack: {}) } } label: { @@ -170,7 +170,7 @@ struct BackportFlareRoot: View { } else { TabView(selection: $selectedTab) { ForEach(items, id: \.name) { tab in - Router(backStack: navigationModel.binding(for: homeTabKey(tab))) { onNavigate in + Router(backStack: navigationModel.binding(for: homeTabRoute(tab))) { onNavigate in homeTabRoute(tab).view(onNavigate: onNavigate, goBack: {}) } .tabItem { @@ -374,15 +374,20 @@ private enum SecondarySidebarStaticRoute: CaseIterable { @MainActor private final class HomeNavigationModel: ObservableObject { - @Published private var backStacks: [String: [Route]] = [:] + @Published private var backStacks: [Route: [Route]] = [:] + @Published private(set) var selectedTopLevelRoute: Route? let navigator = RouterNavigator() - func binding(for key: String) -> Binding<[Route]> { + func binding(for route: Route) -> Binding<[Route]> { Binding( - get: { self.backStacks[key] ?? [] }, - set: { self.backStacks[key] = $0 } + get: { self.backStacks[route] ?? [] }, + set: { self.backStacks[route] = $0 } ) } + + func selectTopLevel(_ route: Route?) { + selectedTopLevelRoute = route + } } private struct SingleColumnFlareRoot: View { @@ -396,6 +401,10 @@ private struct SingleColumnFlareRoot: View { tabs.first { homeTabKey($0) == selectedTab } ?? tabs.first } + private var activeRoute: Route? { + navigationModel.selectedTopLevelRoute ?? activeTab.map(homeTabRoute) + } + var body: some View { GeometryReader { proxy in let showRightSidebar = proxy.size.width >= 900 @@ -403,11 +412,12 @@ private struct SingleColumnFlareRoot: View { VStack(spacing: 8) { ForEach(tabs, id: \.name) { tab in let key = homeTabKey(tab) - let selected = key == activeTab.map { homeTabKey($0) } + let selected = navigationModel.selectedTopLevelRoute == nil && key == activeTab.map { homeTabKey($0) } Button { if selected { NotificationCenter.default.post(name: .tabDoubleTapped, object: key) } else { + navigationModel.selectTopLevel(nil) selectedTab = key } } label: { @@ -470,12 +480,16 @@ private struct SingleColumnFlareRoot: View { Divider() HStack(spacing: 0) { - if let activeTab { + if let activeRoute { Router( - backStack: navigationModel.binding(for: homeTabKey(activeTab)), + backStack: navigationModel.binding(for: activeRoute), navigator: navigationModel.navigator ) { onNavigate in - homeTabRoute(activeTab).view(onNavigate: onNavigate, goBack: {}) + activeRoute.view( + onNavigate: onNavigate, + goBack: {}, + showsSecondaryMenu: !showRightSidebar + ) } .environment(\.horizontalSizeClass, .compact) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -483,7 +497,9 @@ private struct SingleColumnFlareRoot: View { if showRightSidebar { Divider() - SingleColumnSecondarySidebar(navigator: navigationModel.navigator) + SingleColumnSecondarySidebar { route in + navigationModel.selectTopLevel(route) + } .frame(width: 320) } } @@ -498,7 +514,7 @@ private struct SingleColumnFlareRoot: View { } private struct SingleColumnSecondarySidebar: View { - @ObservedObject var navigator: RouterNavigator + let navigate: (Route) -> Void @StateObject private var secondaryTabsPresenter = KotlinPresenter(presenter: SecondaryTabsPresenter()) @StateObject private var loggedInPresenter = KotlinPresenter(presenter: LoggedInPresenter()) @StateObject private var aiAgentEnabledPresenter = KotlinPresenter(presenter: AiAgentEnabledPresenter()) @@ -521,7 +537,7 @@ private struct SingleColumnSecondarySidebar: View { .textFieldStyle(.roundedBorder) .submitLabel(.search) .onSubmit { - navigator.navigate(.search(searchAccount, searchQuery.trimmingCharacters(in: .whitespacesAndNewlines))) + navigate(.search(searchAccount, searchQuery.trimmingCharacters(in: .whitespacesAndNewlines))) } .padding(16) @@ -529,7 +545,7 @@ private struct SingleColumnSecondarySidebar: View { if case .success(let loggedIn) = onEnum(of: loggedInPresenter.state.isLoggedIn), !loggedIn.data.boolValue { Button { - navigator.navigate(.serviceSelect) + navigate(.serviceSelect) } label: { Label("login_title", systemImage: "person.badge.plus") } @@ -542,7 +558,7 @@ private struct SingleColumnSecondarySidebar: View { ForEach(account.tabs, id: \.self) { tab in if let route = route(for: tab) { Button { - navigator.navigate(route) + navigate(route) } label: { Label { Text(tab.title.text) @@ -567,7 +583,7 @@ private struct SingleColumnSecondarySidebar: View { route != .agentHistory || aiAgentEnabledPresenter.state.enabled }, id: \.self) { item in Button { - navigator.navigate(item.route) + navigate(item.route) } label: { Label { Text(item.title) diff --git a/appleApp/ios/UI/Route/Route.swift b/appleApp/ios/UI/Route/Route.swift index c6081744dc..4cde51698a 100644 --- a/appleApp/ios/UI/Route/Route.swift +++ b/appleApp/ios/UI/Route/Route.swift @@ -54,14 +54,15 @@ enum Route: Hashable, Identifiable { @ViewBuilder func view( onNavigate: @escaping (Route) -> Void, - goBack: @escaping () -> Void + goBack: @escaping () -> Void, + showsSecondaryMenu: Bool = true ) -> some View { switch self { case .home: HomeTimelineScreen( toServiceSelect: { onNavigate(.serviceSelect) }, toCompose: { onNavigate(.composeNew) }, toTabSetting: { onNavigate(.tabSettings) }, - toSecondaryMenu: { onNavigate(.secondaryMenu) }, + toSecondaryMenu: showsSecondaryMenu ? { onNavigate(.secondaryMenu) } : nil, onNavigate: onNavigate ) case .timeline(let item): diff --git a/appleApp/ios/UI/Screen/HomeTimelineScreen.swift b/appleApp/ios/UI/Screen/HomeTimelineScreen.swift index 0ea701457d..0d0ed1737b 100644 --- a/appleApp/ios/UI/Screen/HomeTimelineScreen.swift +++ b/appleApp/ios/UI/Screen/HomeTimelineScreen.swift @@ -10,7 +10,7 @@ struct HomeTimelineScreen: View { let toServiceSelect: () -> Void let toCompose: () -> Void let toTabSetting: () -> Void - let toSecondaryMenu: () -> Void + let toSecondaryMenu: (() -> Void)? let onNavigate: (Route) -> Void @Environment(\.globalAppearance) private var globalAppearance @Environment(\.horizontalSizeClass) private var horizontalSizeClass @@ -30,7 +30,7 @@ struct HomeTimelineScreen: View { toServiceSelect: @escaping () -> Void, toCompose: @escaping () -> Void, toTabSetting: @escaping () -> Void, - toSecondaryMenu: @escaping () -> Void, + toSecondaryMenu: (() -> Void)?, onNavigate: @escaping (Route) -> Void ) { self.toCompose = toCompose @@ -62,11 +62,13 @@ struct HomeTimelineScreen: View { if tabs.isEmpty { ContentUnavailableView("tab_settings_title", systemImage: "square.grid.2x2") .toolbar { - ToolbarItem(placement: .topBarLeading) { - Image(fontAwesome: .gear) - .onTapGesture { - toSecondaryMenu() - } + if let toSecondaryMenu { + ToolbarItem(placement: .topBarLeading) { + Image(fontAwesome: .gear) + .onTapGesture { + toSecondaryMenu() + } + } } ToolbarItem(placement: .primaryAction) { Button { @@ -248,24 +250,26 @@ struct HomeTimelineScreen: View { @ToolbarContentBuilder private var leadingToolbarContent: some ToolbarContent { - ToolbarItem(placement: .topBarLeading) { - StateView(state: activeAccountPresenter.state.user) { user in - if user.avatar == nil { - Image(fontAwesome: .gear) - } else { - if #available(iOS 26.0, *) { - AvatarView(data: user.avatar?.url, customHeader: user.avatar?.customHeaders) + if let toSecondaryMenu { + ToolbarItem(placement: .topBarLeading) { + StateView(state: activeAccountPresenter.state.user) { user in + if user.avatar == nil { + Image(fontAwesome: .gear) } else { - AvatarView(data: user.avatar?.url, customHeader: user.avatar?.customHeaders) - .frame(width: 24, height: 24) + if #available(iOS 26.0, *) { + AvatarView(data: user.avatar?.url, customHeader: user.avatar?.customHeaders) + } else { + AvatarView(data: user.avatar?.url, customHeader: user.avatar?.customHeaders) + .frame(width: 24, height: 24) + } } + } errorContent: { _ in + Image(fontAwesome: .gear) + } loadingContent: { + Image(fontAwesome: .gear) + }.onTapGesture { + toSecondaryMenu() } - } errorContent: { _ in - Image(fontAwesome: .gear) - } loadingContent: { - Image(fontAwesome: .gear) - }.onTapGesture { - toSecondaryMenu() } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index 3c64de5887..35ffea8a76 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -539,7 +539,7 @@ internal fun WindowScope.FlareApp(backButtonState: NavigationBackButtonState) { secondaryTabs = state.items, isLoggedIn = state.isLoggedIn.takeSuccess(), aiAgentEnabled = state.aiAgentEnabled, - navigate = state::navigate, + navigate = state::navigateTopLevel, modifier = Modifier.width(336.dp), ) } @@ -747,6 +747,13 @@ private fun presenter(uriHandler: UriHandler) = } } + fun navigateTopLevel(route: Route) { + when (route) { + is Route.UrlRoute -> uriHandler.openUri(route.url) + else -> topLevelBackStack.takeSuccess()?.pushTopLevel(route) + } + } + fun replace(route: Route) { when (route) { is Route.UrlRoute -> { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/TopLevelBackStack.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/TopLevelBackStack.kt index f4526690c7..0ab2f1271c 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/TopLevelBackStack.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/TopLevelBackStack.kt @@ -9,8 +9,9 @@ import kotlinx.collections.immutable.toImmutableList internal class TopLevelBackStack( private val startKey: Route, - private val topLevelRoutes: List, + topLevelRoutes: List, ) { + private val topLevelRoutes = topLevelRoutes.toMutableSet() private val _stack = mutableStateListOf( startKey, @@ -25,18 +26,28 @@ internal class TopLevelBackStack( private set fun push(route: Route) { + if (route in topLevelRoutes) { + pushTopLevel(route) + return + } + if (currentRoute == route) return + + _stack.add(route) + updateEntry() + } + + fun pushTopLevel(route: Route) { + topLevelRoutes.add(route) if (currentRoute == route) { + updateEntry() return } - if (route in topLevelRoutes) { - val entry = stack.find { it == route } - if (entry != null) { - // remove rest of the stack and set the entry as current - _stack.removeAll { it !in topLevelRoutes || it == entry } - _stack.add(entry) - } else { - _stack.add(route) - } + + val entry = stack.find { it == route } + if (entry != null) { + // remove rest of the stack and set the entry as current + _stack.removeAll { it !in topLevelRoutes || it == entry } + _stack.add(entry) } else { _stack.add(route) } From 44e8a6dbe1a619c165652788b80b497d98a40cb1 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 1 Sep 2026 13:58:50 +0800 Subject: [PATCH 04/13] fix: keep compact timelines single column --- .../status/LazyStatusVerticalStaggeredGrid.kt | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusVerticalStaggeredGrid.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusVerticalStaggeredGrid.kt index 6ac9384409..5f42c085d3 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusVerticalStaggeredGrid.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusVerticalStaggeredGrid.kt @@ -28,6 +28,7 @@ import dev.dimension.flare.data.model.TimelineDisplayMode import dev.dimension.flare.ui.common.plus import dev.dimension.flare.ui.component.LocalTimelineAppearance import dev.dimension.flare.ui.component.platform.isBigScreen +import dev.dimension.flare.ui.component.platform.isCompatScreen import dev.dimension.flare.ui.theme.PlatformTheme import dev.dimension.flare.ui.theme.isLightTheme import dev.dimension.flare.ui.theme.screenHorizontalPadding @@ -40,7 +41,7 @@ import kotlinx.coroutines.flow.map @Composable public fun LazyStatusVerticalStaggeredGrid( modifier: Modifier = Modifier, - columns: StaggeredGridCells = StaggeredGridCells.Adaptive(320.dp), + columns: StaggeredGridCells? = null, state: LazyStaggeredGridState = rememberLazyStaggeredGridState(), contentPadding: PaddingValues = PaddingValues(0.dp), reverseLayout: Boolean = false, @@ -56,10 +57,12 @@ public fun LazyStatusVerticalStaggeredGrid( allowGalleryMode: Boolean = false, content: LazyStaggeredGridScope.() -> Unit, ) { + val bigScreenLayout = isBigScreen() + val compactLayout = isCompatScreen() val displayMode = LocalTimelineAppearance.current.timelineDisplayMode val effectiveMode = when { - displayMode == TimelineDisplayMode.Plain && isBigScreen() -> { + displayMode == TimelineDisplayMode.Plain && bigScreenLayout -> { TimelineDisplayMode.Card } @@ -94,10 +97,14 @@ public fun LazyStatusVerticalStaggeredGrid( }.collectAsState(0) val isWideViewport = with(density) { viewportWidthPx.toDp() } >= 600.dp val effectiveColumns = - if (effectiveMode == TimelineDisplayMode.Gallery) { - StaggeredGridCells.Adaptive(if (isWideViewport) 240.dp else 160.dp) - } else { - columns + when { + effectiveMode == TimelineDisplayMode.Gallery -> { + StaggeredGridCells.Adaptive(if (isWideViewport) 240.dp else 160.dp) + } + + columns != null -> columns + compactLayout -> StaggeredGridCells.Fixed(1) + else -> StaggeredGridCells.Adaptive(320.dp) } val columnCount by remember(state, effectiveColumns) { snapshotFlow { state.layoutInfo.viewportSize.width } From 17806143b2c5509116d1efe98dd0baabbda6148e Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 1 Sep 2026 14:13:53 +0800 Subject: [PATCH 05/13] fix: constrain single-column timeline items --- .../UI/Component/CollectionViewTimeline.swift | 16 +++++++-- .../ui/component/status/LazyStatusItems.kt | 34 ++++++++++++++++--- .../status/LazyStatusVerticalStaggeredGrid.kt | 14 ++++++-- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/appleApp/ios/UI/Component/CollectionViewTimeline.swift b/appleApp/ios/UI/Component/CollectionViewTimeline.swift index 782f10156b..266eb692a1 100644 --- a/appleApp/ios/UI/Component/CollectionViewTimeline.swift +++ b/appleApp/ios/UI/Component/CollectionViewTimeline.swift @@ -8,6 +8,7 @@ import AVFoundation enum TimelineUIKitLayoutMetrics { static let horizontalInset: CGFloat = 16 + static let maximumTimelineItemWidth: CGFloat = 480 static let columnSpacing: CGFloat = 8 static let rowSpacing: CGFloat = 2 static let timelinePlaceholderCount = 5 @@ -494,11 +495,22 @@ final class UITimelineCollectionViewController: UIViewController, UICollectionVi } private func makeSingleColumnLayout() -> UICollectionViewLayout { - return UICollectionViewCompositionalLayout { sectionIndex, _ in + return UICollectionViewCompositionalLayout { sectionIndex, environment in let isAccessorySection = !self.accessoryItems.isEmpty && sectionIndex == 0 - let horizontalInset = isAccessorySection || self.appearance.isPlainTimelineDisplayMode + let mainSectionIndex = self.accessoryItems.isEmpty ? 0 : 1 + let isMainTimelineSection = self.contentKind == .timeline && + sectionIndex == mainSectionIndex && + !self.mainSectionUsesFullWidth + var horizontalInset = isAccessorySection || self.appearance.isPlainTimelineDisplayMode ? 0 : TimelineUIKitLayoutMetrics.horizontalInset + if isMainTimelineSection { + horizontalInset = max( + horizontalInset, + (environment.container.effectiveContentSize.width - + TimelineUIKitLayoutMetrics.maximumTimelineItemWidth) / 2 + ) + } let itemSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1), heightDimension: .estimated(180) diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusItems.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusItems.kt index e0ee4003f6..a32d565b1d 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusItems.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusItems.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.foundation.shape.CircleShape @@ -39,6 +40,7 @@ import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.placeholder import dev.dimension.flare.ui.component.platform.PlatformLinearProgressIndicator import dev.dimension.flare.ui.component.platform.PlatformText +import dev.dimension.flare.ui.component.platform.isCompatScreen import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.theme.PlatformTheme import dev.dimension.flare.ui.theme.screenHorizontalPadding @@ -66,13 +68,12 @@ public fun LazyStaggeredGridScope.status( if (mode == TimelineDisplayMode.Gallery) { GalleryTimelineItem(item = item) } else { - AdaptiveCard( + TimelineAdaptiveCard( // modifier = // Modifier // .animateItem(), index = index, totalCount = itemCount, - respectTimelineMode = true, content = { StatusItem( item, @@ -139,10 +140,9 @@ public fun LazyStaggeredGridScope.status( public fun LazyStaggeredGridScope.statusLoadingPlaceholders() { val placeholderCount = 10 items(placeholderCount) { index -> - AdaptiveCard( + TimelineAdaptiveCard( index = index, totalCount = placeholderCount, - respectTimelineMode = true, content = { OnLoading() }, @@ -150,6 +150,32 @@ public fun LazyStaggeredGridScope.statusLoadingPlaceholders() { } } +@Composable +private fun TimelineAdaptiveCard( + modifier: Modifier = Modifier, + index: Int, + totalCount: Int, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.TopCenter, + ) { + AdaptiveCard( + modifier = + if (isCompatScreen()) { + Modifier.widthIn(max = 480.dp).fillMaxWidth() + } else { + Modifier.fillMaxWidth() + }, + index = index, + totalCount = totalCount, + respectTimelineMode = true, + content = content, + ) + } +} + public fun LazyStaggeredGridScope.appendStateUI(success: PagingState.Success) { success.appendState .onError { diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusVerticalStaggeredGrid.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusVerticalStaggeredGrid.kt index 5f42c085d3..157b12fc35 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusVerticalStaggeredGrid.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusVerticalStaggeredGrid.kt @@ -102,9 +102,17 @@ public fun LazyStatusVerticalStaggeredGrid( StaggeredGridCells.Adaptive(if (isWideViewport) 240.dp else 160.dp) } - columns != null -> columns - compactLayout -> StaggeredGridCells.Fixed(1) - else -> StaggeredGridCells.Adaptive(320.dp) + columns != null -> { + columns + } + + compactLayout -> { + StaggeredGridCells.Fixed(1) + } + + else -> { + StaggeredGridCells.Adaptive(320.dp) + } } val columnCount by remember(state, effectiveColumns) { snapshotFlow { state.layoutInfo.viewportSize.width } From ebded7c4f31668ee4c1501d146a672472b3340be Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 1 Sep 2026 14:22:30 +0800 Subject: [PATCH 06/13] fix: respect safe areas in secondary sidebars The new Android and Compose Desktop sidebars bypassed the inset-aware navigation content, so controls could overlap system bars or the desktop title bar. --- .../flare/ui/screen/home/HomeSecondarySidebar.kt | 16 +++++++++++++++- .../dev/dimension/flare/HomeSecondarySidebar.kt | 1 + 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt index 8182366e6a..e11c5ff560 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt @@ -3,9 +3,16 @@ package dev.dimension.flare.ui.screen.home import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -70,7 +77,14 @@ internal fun HomeSecondarySidebar( } LazyColumn( - modifier = modifier.fillMaxHeight(), + modifier = + modifier + .fillMaxHeight() + .windowInsetsPadding( + WindowInsets.systemBars + .union(WindowInsets.displayCutout) + .only(WindowInsetsSides.End + WindowInsetsSides.Vertical), + ), contentPadding = PaddingValues(vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/HomeSecondarySidebar.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/HomeSecondarySidebar.kt index b09ff2bbfe..083f1b636c 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/HomeSecondarySidebar.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/HomeSecondarySidebar.kt @@ -72,6 +72,7 @@ internal fun HomeSecondarySidebar( modifier = modifier .fillMaxHeight() + .padding(LocalWindowPadding.current) .verticalScroll(scrollState) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), From 9756c37e1f7b7509679b1c7e2487cd9e96601da5 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 1 Sep 2026 14:30:14 +0800 Subject: [PATCH 07/13] fix: lock Android rail beside fixed sidebar Wide single-column layouts already expose secondary destinations in the fixed right sidebar. Keep the navigation rail collapsed there so expanding it cannot hide or duplicate that sidebar. --- .../flare/ui/screen/home/HomeScreen.kt | 59 +++++++++++-------- .../ui/screen/home/HomeScreenLayoutTest.kt | 15 +++++ 2 files changed, 50 insertions(+), 24 deletions(-) create mode 100644 app/src/test/java/dev/dimension/flare/ui/screen/home/HomeScreenLayoutTest.kt diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt index 731bbbe92f..2521eea925 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid @@ -141,10 +142,17 @@ internal fun HomeScreen(afterInit: () -> Unit) { val singleColumn = globalAppearance.largeScreenLayoutMode == LargeScreenLayoutMode.SingleColumn && layoutType != NavigationSuiteType.NavigationBar - val hasRightSidebar = singleColumn && maxWidth >= 1024.dp - val showRightSidebar = - hasRightSidebar && - state.wideNavigationRailState.currentValue == WideNavigationRailValue.Collapsed + val hasRightSidebar = usesFixedSecondarySidebar(singleColumn, maxWidth) + val openDrawer = { + if (!hasRightSidebar) { + state.openDrawer() + } + } + LaunchedEffect(hasRightSidebar) { + if (hasRightSidebar) { + state.wideNavigationRailState.collapse() + } + } NavigationSuiteScaffold2( wideNavigationRailState = state.wideNavigationRailState, modifier = Modifier.fillMaxSize(), @@ -161,25 +169,23 @@ internal fun HomeScreen(afterInit: () -> Unit) { ), railHeader = { if (layoutType == NavigationSuiteType.NavigationRail) { - IconButton( - onClick = { - state.openDrawer() - }, - modifier = - Modifier - .padding( - horizontal = 24.dp, - ).padding(top = 12.dp, bottom = 4.dp), - ) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Bars, - contentDescription = null, - ) + if (!hasRightSidebar) { + IconButton( + onClick = openDrawer, + modifier = + Modifier + .padding( + horizontal = 24.dp, + ).padding(top = 12.dp, bottom = 4.dp), + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Bars, + contentDescription = null, + ) + } } - if (layoutType == NavigationSuiteType.NavigationRail && - state.canComposeState.takeSuccess() == true - ) { + if (state.canComposeState.takeSuccess() == true) { SharedTransitionLayout { AnimatedContent( state.wideNavigationRailState.currentValue, @@ -542,7 +548,7 @@ internal fun HomeScreen(afterInit: () -> Unit) { ) { Router( backStack = backStack, - openDrawer = state::openDrawer, + openDrawer = openDrawer, navigate = state::navigate, onBack = state::goBack, modifier = Modifier.fillMaxSize(), @@ -550,7 +556,7 @@ internal fun HomeScreen(afterInit: () -> Unit) { ) } } - if (showRightSidebar) { + if (hasRightSidebar) { VerticalDivider() HomeSecondarySidebar( secondaryTabs = state.secondaryTabsState, @@ -567,7 +573,7 @@ internal fun HomeScreen(afterInit: () -> Unit) { } else { Router( backStack = backStack, - openDrawer = state::openDrawer, + openDrawer = openDrawer, navigate = state::navigate, onBack = state::goBack, ) @@ -586,6 +592,11 @@ internal fun HomeScreen(afterInit: () -> Unit) { } } +internal fun usesFixedSecondarySidebar( + singleColumn: Boolean, + availableWidth: Dp, +): Boolean = singleColumn && availableWidth >= 1024.dp + internal fun getDirection(data: SecondaryTabsPresenter.Tab): Route? = when (val target = data.destination) { is SecondaryTabsPresenter.Destination.Route -> { diff --git a/app/src/test/java/dev/dimension/flare/ui/screen/home/HomeScreenLayoutTest.kt b/app/src/test/java/dev/dimension/flare/ui/screen/home/HomeScreenLayoutTest.kt new file mode 100644 index 0000000000..c9321f520e --- /dev/null +++ b/app/src/test/java/dev/dimension/flare/ui/screen/home/HomeScreenLayoutTest.kt @@ -0,0 +1,15 @@ +package dev.dimension.flare.ui.screen.home + +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HomeScreenLayoutTest { + @Test + fun fixedSecondarySidebarIsOnlyUsedByWideSingleColumnLayouts() { + assertFalse(usesFixedSecondarySidebar(singleColumn = false, availableWidth = 1200.dp)) + assertFalse(usesFixedSecondarySidebar(singleColumn = true, availableWidth = 1023.dp)) + assertTrue(usesFixedSecondarySidebar(singleColumn = true, availableWidth = 1024.dp)) + } +} From ce0a7e34efa1e8c21a01317aca7ee46135844054 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 1 Sep 2026 15:17:59 +0800 Subject: [PATCH 08/13] fix: avoid duplicate secondary sidebar keys --- .../dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt index e11c5ff560..59d7055229 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt @@ -156,7 +156,7 @@ internal fun HomeSecondarySidebar( } account.tabs.drop(1).forEach { tab -> val route = getDirection(tab) ?: return@forEach - item(key = "${account.accountType}-$route") { + item { NavigationDrawerItem( label = { dev.dimension.flare.ui.component From 2aeb60d5fae2b8e224ea46fc825e52e898dd59f7 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 1 Sep 2026 15:31:50 +0800 Subject: [PATCH 09/13] feat: make secondary sidebar accounts expandable --- .../ui/screen/home/HomeSecondarySidebar.kt | 103 +++++++++++------- 1 file changed, 63 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt index 59d7055229..15ab3dd5aa 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt @@ -1,5 +1,6 @@ package dev.dimension.flare.ui.screen.home +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -24,6 +25,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -34,6 +36,8 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.CaretDown +import compose.icons.fontawesomeicons.solid.CaretUp import compose.icons.fontawesomeicons.solid.ClockRotateLeft import compose.icons.fontawesomeicons.solid.Gear import compose.icons.fontawesomeicons.solid.MagnifyingGlass @@ -68,6 +72,7 @@ internal fun HomeSecondarySidebar( ) { val accounts = secondaryTabs.takeSuccess().orEmpty() val searchAccount = accounts.firstOrNull()?.accountType ?: AccountType.Guest + val expandedAccounts = remember { mutableStateMapOf() } var query by remember { mutableStateOf("") } val keyboardController = LocalSoftwareKeyboardController.current val submitSearch = { @@ -129,54 +134,72 @@ internal fun HomeSecondarySidebar( } accounts.forEach { account -> - val profileRoute = account.tabs.firstOrNull()?.let(::getDirection) + val expanded = expandedAccounts[account.accountType] == true item(key = "account-${account.accountType}") { - NavigationDrawerItem( - label = { - Column { - account.user.takeSuccess()?.let { user -> - RichText(user.name, maxLines = 1) - Text( - user.handle.canonical, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - ) - } - } - }, - selected = profileRoute != null && currentRoute == profileRoute, - onClick = { profileRoute?.let(navigate) }, - icon = { - account.user.takeSuccess()?.let { user -> - AvatarComponent(user.avatar) - } - }, - modifier = Modifier.padding(horizontal = SidebarHorizontalPadding), - ) - } - account.tabs.drop(1).forEach { tab -> - val route = getDirection(tab) ?: return@forEach - item { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { NavigationDrawerItem( label = { - dev.dimension.flare.ui.component - .Text(tab.title.asText()) + Column { + account.user.takeSuccess()?.let { user -> + RichText(user.name, maxLines = 1) + Text( + user.handle.canonical, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + ) + } + } + }, + selected = expanded, + onClick = { + expandedAccounts[account.accountType] = !expanded }, - selected = currentRoute == route, - onClick = { navigate(route) }, icon = { - TabIcon( - icon = tab.icon.asType(), - title = tab.title.asText(), - iconOnly = true, + account.user.takeSuccess()?.let { user -> + AvatarComponent(user.avatar) + } + }, + badge = { + FAIcon( + imageVector = + if (expanded) { + FontAwesomeIcons.Solid.CaretUp + } else { + FontAwesomeIcons.Solid.CaretDown + }, + contentDescription = null, ) }, - modifier = - Modifier.padding( - start = SidebarHorizontalPadding + 24.dp, - end = SidebarHorizontalPadding, - ), + modifier = Modifier.padding(horizontal = SidebarHorizontalPadding), ) + AnimatedVisibility(expanded) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + account.tabs.forEach { tab -> + getDirection(tab)?.let { route -> + NavigationDrawerItem( + label = { + dev.dimension.flare.ui.component + .Text(tab.title.asText()) + }, + selected = currentRoute == route, + onClick = { navigate(route) }, + icon = { + TabIcon( + icon = tab.icon.asType(), + title = tab.title.asText(), + iconOnly = true, + ) + }, + modifier = + Modifier.padding( + start = SidebarHorizontalPadding + 24.dp, + end = SidebarHorizontalPadding, + ), + ) + } + } + } + } } } } From 8c8f5d6e1615d3c9f91c57b59ad5b95bc04e4b87 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 1 Sep 2026 15:41:40 +0800 Subject: [PATCH 10/13] style: align secondary sidebar accounts with drawer --- .../ui/screen/home/HomeSecondarySidebar.kt | 90 ++++++++++++------- 1 file changed, 56 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt index 15ab3dd5aa..941263025a 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.plus import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.windowInsetsPadding @@ -19,9 +20,11 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedListItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -57,6 +60,7 @@ import dev.dimension.flare.ui.model.asType import dev.dimension.flare.ui.model.takeSuccess import dev.dimension.flare.ui.presenter.home.SecondaryTabsPresenter import dev.dimension.flare.ui.route.Route +import dev.dimension.flare.ui.theme.segmentedShapes2 import kotlinx.collections.immutable.ImmutableList private val SidebarHorizontalPadding = 16.dp @@ -133,12 +137,36 @@ internal fun HomeSecondarySidebar( } } - accounts.forEach { account -> + accounts.forEachIndexed { accountIndex, account -> val expanded = expandedAccounts[account.accountType] == true + val tabs = + account.tabs.mapNotNull { tab -> + getDirection(tab)?.let { route -> tab to route } + } + val joinsPrevious = + accountIndex > 0 && + !expanded && + expandedAccounts[accounts[accountIndex - 1].accountType] != true + val joinsNext = + accountIndex < accounts.lastIndex && + !expanded && + expandedAccounts[accounts[accountIndex + 1].accountType] != true item(key = "account-${account.accountType}") { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - NavigationDrawerItem( - label = { + val headerShapes = + when { + joinsPrevious && joinsNext -> ListItemDefaults.segmentedShapes2(1, 3) + joinsPrevious -> ListItemDefaults.segmentedShapes2(1, 2) + joinsNext -> ListItemDefaults.segmentedShapes2(0, 2) + else -> ListItemDefaults.segmentedShapes2(0, 1) + } + Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { + SegmentedListItem( + checked = expanded, + onCheckedChange = { + expandedAccounts[account.accountType] = it + }, + shapes = headerShapes, + content = { Column { account.user.takeSuccess()?.let { user -> RichText(user.name, maxLines = 1) @@ -150,16 +178,12 @@ internal fun HomeSecondarySidebar( } } }, - selected = expanded, - onClick = { - expandedAccounts[account.accountType] = !expanded - }, - icon = { + leadingContent = { account.user.takeSuccess()?.let { user -> AvatarComponent(user.avatar) } }, - badge = { + trailingContent = { FAIcon( imageVector = if (expanded) { @@ -173,30 +197,28 @@ internal fun HomeSecondarySidebar( modifier = Modifier.padding(horizontal = SidebarHorizontalPadding), ) AnimatedVisibility(expanded) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - account.tabs.forEach { tab -> - getDirection(tab)?.let { route -> - NavigationDrawerItem( - label = { - dev.dimension.flare.ui.component - .Text(tab.title.asText()) - }, - selected = currentRoute == route, - onClick = { navigate(route) }, - icon = { - TabIcon( - icon = tab.icon.asType(), - title = tab.title.asText(), - iconOnly = true, - ) - }, - modifier = - Modifier.padding( - start = SidebarHorizontalPadding + 24.dp, - end = SidebarHorizontalPadding, - ), - ) - } + Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { + tabs.forEachIndexed { tabIndex, (tab, route) -> + SegmentedListItem( + selected = currentRoute == route, + onClick = { navigate(route) }, + shapes = ListItemDefaults.segmentedShapes2(tabIndex, tabs.size), + content = { + dev.dimension.flare.ui.component + .Text(tab.title.asText()) + }, + leadingContent = { + TabIcon( + icon = tab.icon.asType(), + title = tab.title.asText(), + iconOnly = true, + ) + }, + modifier = Modifier.padding(horizontal = SidebarHorizontalPadding), + contentPadding = + ListItemDefaults.ContentPadding + .plus(PaddingValues(start = 16.dp)), + ) } } } From 51856762e21c782813d8a92eab2a8afd9a627423 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 1 Sep 2026 15:57:57 +0800 Subject: [PATCH 11/13] fix: apply sidebar insets as scroll padding --- .../ui/screen/home/HomeSecondarySidebar.kt | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt index 941263025a..05cc8aeaf5 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth @@ -14,7 +15,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.plus import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.union -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -84,17 +84,16 @@ internal fun HomeSecondarySidebar( keyboardController?.hide() Unit } + val contentPadding = + WindowInsets.systemBars + .union(WindowInsets.displayCutout) + .only(WindowInsetsSides.End + WindowInsetsSides.Vertical) + .asPaddingValues() + .plus(PaddingValues(vertical = 16.dp)) LazyColumn( - modifier = - modifier - .fillMaxHeight() - .windowInsetsPadding( - WindowInsets.systemBars - .union(WindowInsets.displayCutout) - .only(WindowInsetsSides.End + WindowInsetsSides.Vertical), - ), - contentPadding = PaddingValues(vertical = 16.dp), + modifier = modifier.fillMaxHeight(), + contentPadding = contentPadding, verticalArrangement = Arrangement.spacedBy(4.dp), ) { item { From a61ca1b4096516416f6a05c9b8fec6ec7af8b527 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 1 Sep 2026 17:34:18 +0800 Subject: [PATCH 12/13] feat(ios): use TabView for single-column navigation --- appleApp/ios/UI/FlareRoot.swift | 119 +++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 3 deletions(-) diff --git a/appleApp/ios/UI/FlareRoot.swift b/appleApp/ios/UI/FlareRoot.swift index d201df2fc8..ee1cd51c62 100644 --- a/appleApp/ios/UI/FlareRoot.swift +++ b/appleApp/ios/UI/FlareRoot.swift @@ -23,6 +23,19 @@ struct FlareRoot: View { @StateObject private var navigationModel = HomeNavigationModel() @State var selectedTab: String? @State private var reloginRoute: Route? + + private var singleColumnSecondaryRoutes: [Route] { + var routes: [Route] = [] + if case .success(let data) = onEnum(of: secondaryTabsPresenter.state.items) { + routes += data.data.cast(SecondaryTabsPresenter.Item.self).flatMap { item in + item.tabs.compactMap { route(for: $0) } + } + } + routes += SecondarySidebarStaticRoute.allCases + .filter { $0 != .agentHistory || aiAgentEnabledPresenter.state.enabled } + .map(\.route) + return routes + } var body: some View { StateView(state: homeTabsPresenter.state.tabs) { tabs in @@ -33,7 +46,9 @@ struct FlareRoot: View { tabs: items, selectedTab: $selectedTab, notificationCount: Int(notificationBadgePresenter.state.count), - navigationModel: navigationModel + navigationModel: navigationModel, + secondaryRoutes: singleColumnSecondaryRoutes, + usesTabView: true ) } else { TabView(selection: $selectedTab) { @@ -165,7 +180,9 @@ struct BackportFlareRoot: View { tabs: items, selectedTab: $selectedTab, notificationCount: Int(notificationBadgePresenter.state.count), - navigationModel: navigationModel + navigationModel: navigationModel, + secondaryRoutes: [], + usesTabView: false ) } else { TabView(selection: $selectedTab) { @@ -395,6 +412,8 @@ private struct SingleColumnFlareRoot: View { @Binding var selectedTab: String? let notificationCount: Int @ObservedObject var navigationModel: HomeNavigationModel + let secondaryRoutes: [Route] + let usesTabView: Bool @Environment(\.globalAppearance) private var globalAppearance private var activeTab: HomeTabsPresenterStateHomeTabs? { @@ -480,7 +499,15 @@ private struct SingleColumnFlareRoot: View { Divider() HStack(spacing: 0) { - if let activeRoute { + if #available(iOS 18.0, *), usesTabView { + SingleColumnTopLevelTabView( + tabs: tabs, + selectedTab: $selectedTab, + secondaryRoutes: secondaryRoutes, + showRightSidebar: showRightSidebar, + navigationModel: navigationModel + ) + } else if let activeRoute { Router( backStack: navigationModel.binding(for: activeRoute), navigator: navigationModel.navigator @@ -513,6 +540,92 @@ private struct SingleColumnFlareRoot: View { } } +@available(iOS 18.0, *) +private struct SingleColumnTopLevelTabView: View { + let tabs: [HomeTabsPresenterStateHomeTabs] + @Binding var selectedTab: String? + let secondaryRoutes: [Route] + let showRightSidebar: Bool + @ObservedObject var navigationModel: HomeNavigationModel + + private var activeRoute: Route? { + navigationModel.selectedTopLevelRoute ?? + tabs.first { homeTabKey($0) == selectedTab }.map(homeTabRoute) ?? + tabs.first.map(homeTabRoute) + } + + private var availableSecondaryRoutes: [Route] { + var routes = secondaryRoutes + if let selectedRoute = navigationModel.selectedTopLevelRoute, + !routes.contains(selectedRoute), + !tabs.contains(where: { homeTabRoute($0) == selectedRoute }) { + routes.append(selectedRoute) + } + return routes + } + + private var selection: Binding { + Binding( + get: { activeRoute }, + set: { route in + guard let route else { return } + if let tab = tabs.first(where: { homeTabRoute($0) == route }) { + navigationModel.selectTopLevel(nil) + selectedTab = homeTabKey(tab) + } else { + navigationModel.selectTopLevel(route) + } + } + ) + } + + var body: some View { + TabView(selection: selection) { + ForEach(tabs, id: \.name) { tab in + let route = homeTabRoute(tab) + Tab(value: route) { + topLevelContent(route) + } label: { + Label { + Text(homeTabTitle(tab)) + } icon: { + Image(fontAwesome: homeTabIcon(tab)) + } + } + } + + ForEach(availableSecondaryRoutes, id: \.self) { route in + Tab(value: route) { + topLevelContent(route) + } label: { + Label("more", systemImage: "ellipsis") + } + .tabPlacement(.sidebarOnly) + } + } + .tabViewStyle(.tabBarOnly) + .introspect(.tabView, on: .iOS(.v18, .v26, .v27)) { tabBarController in + tabBarController.setTabBarHidden(true, animated: false) + } + } + + @ViewBuilder + private func topLevelContent(_ route: Route) -> some View { + Router( + backStack: navigationModel.binding(for: route), + navigator: activeRoute == route ? navigationModel.navigator : nil + ) { onNavigate in + route.view( + onNavigate: onNavigate, + goBack: {}, + showsSecondaryMenu: !showRightSidebar + ) + } + .environment(\.horizontalSizeClass, .compact) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + private struct SingleColumnSecondarySidebar: View { let navigate: (Route) -> Void @StateObject private var secondaryTabsPresenter = KotlinPresenter(presenter: SecondaryTabsPresenter()) From 016f5db63c44953de4d6c7d4653c670d7e541032 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Wed, 2 Sep 2026 10:28:36 +0800 Subject: [PATCH 13/13] refactor: simplify single-column navigation --- .../ui/screen/home/HomeSecondarySidebar.kt | 91 +++------- appleApp/ios/UI/FlareRoot.swift | 159 +++++++----------- appleApp/ios/UI/Route/Router.swift | 23 +-- .../ios/UI/Screen/SecondaryTabsScreen.swift | 83 ++++----- .../ui/component/status/LazyStatusItems.kt | 3 +- .../main/kotlin/dev/dimension/flare/App.kt | 69 +------- .../dimension/flare/HomeSecondarySidebar.kt | 127 +++++++------- 7 files changed, 192 insertions(+), 363 deletions(-) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt index 05cc8aeaf5..8a59a1c23d 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeSecondarySidebar.kt @@ -7,14 +7,12 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.plus -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -28,11 +26,11 @@ import androidx.compose.material3.SegmentedListItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -76,7 +74,6 @@ internal fun HomeSecondarySidebar( ) { val accounts = secondaryTabs.takeSuccess().orEmpty() val searchAccount = accounts.firstOrNull()?.accountType ?: AccountType.Guest - val expandedAccounts = remember { mutableStateMapOf() } var query by remember { mutableStateOf("") } val keyboardController = LocalSoftwareKeyboardController.current val submitSearch = { @@ -85,11 +82,20 @@ internal fun HomeSecondarySidebar( Unit } val contentPadding = - WindowInsets.systemBars - .union(WindowInsets.displayCutout) + WindowInsets.safeDrawing .only(WindowInsetsSides.End + WindowInsetsSides.Vertical) .asPaddingValues() .plus(PaddingValues(vertical = 16.dp)) + val sidebarRoutes = + buildList> { + add(Triple(R.string.draft_box_title, FontAwesomeIcons.Solid.PenToSquare, Route.DraftBox)) + add(Triple(R.string.settings_rss_management_title, FontAwesomeIcons.Solid.SquareRss, Route.Rss.Sources)) + add(Triple(R.string.settings_local_history_title, FontAwesomeIcons.Solid.ClockRotateLeft, Route.Settings.LocalHistory)) + if (aiAgentEnabled) { + add(Triple(R.string.agent_history_title, FontAwesomeIcons.Solid.Robot, Route.Settings.AgentHistory)) + } + add(Triple(R.string.settings_title, FontAwesomeIcons.Solid.Gear, Route.Settings.Main)) + } LazyColumn( modifier = modifier.fillMaxHeight(), @@ -136,35 +142,18 @@ internal fun HomeSecondarySidebar( } } - accounts.forEachIndexed { accountIndex, account -> - val expanded = expandedAccounts[account.accountType] == true + accounts.forEach { account -> val tabs = account.tabs.mapNotNull { tab -> getDirection(tab)?.let { route -> tab to route } } - val joinsPrevious = - accountIndex > 0 && - !expanded && - expandedAccounts[accounts[accountIndex - 1].accountType] != true - val joinsNext = - accountIndex < accounts.lastIndex && - !expanded && - expandedAccounts[accounts[accountIndex + 1].accountType] != true item(key = "account-${account.accountType}") { - val headerShapes = - when { - joinsPrevious && joinsNext -> ListItemDefaults.segmentedShapes2(1, 3) - joinsPrevious -> ListItemDefaults.segmentedShapes2(1, 2) - joinsNext -> ListItemDefaults.segmentedShapes2(0, 2) - else -> ListItemDefaults.segmentedShapes2(0, 1) - } + var expanded by remember(account.accountType) { mutableStateOf(false) } Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { SegmentedListItem( checked = expanded, - onCheckedChange = { - expandedAccounts[account.accountType] = it - }, - shapes = headerShapes, + onCheckedChange = { expanded = it }, + shapes = ListItemDefaults.segmentedShapes2(0, 1), content = { Column { account.user.takeSuccess()?.let { user -> @@ -228,48 +217,16 @@ internal fun HomeSecondarySidebar( item { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) } - item { - SidebarItem( - label = stringResource(R.string.draft_box_title), - selected = currentRoute is Route.DraftBox, - icon = FontAwesomeIcons.Solid.PenToSquare, - onClick = { navigate(Route.DraftBox) }, - ) - } - item { - SidebarItem( - label = stringResource(R.string.settings_rss_management_title), - selected = currentRoute is Route.Rss.Sources, - icon = FontAwesomeIcons.Solid.SquareRss, - onClick = { navigate(Route.Rss.Sources) }, - ) - } - item { - SidebarItem( - label = stringResource(R.string.settings_local_history_title), - selected = currentRoute is Route.Settings.LocalHistory, - icon = FontAwesomeIcons.Solid.ClockRotateLeft, - onClick = { navigate(Route.Settings.LocalHistory) }, - ) - } - if (aiAgentEnabled) { - item { + sidebarRoutes.forEach { (label, icon, route) -> + item(key = route) { SidebarItem( - label = stringResource(R.string.agent_history_title), - selected = currentRoute is Route.Settings.AgentHistory, - icon = FontAwesomeIcons.Solid.Robot, - onClick = { navigate(Route.Settings.AgentHistory) }, + label = stringResource(label), + selected = currentRoute == route, + icon = icon, + onClick = { navigate(route) }, ) } } - item { - SidebarItem( - label = stringResource(R.string.settings_title), - selected = currentRoute is Route.Settings.Main, - icon = FontAwesomeIcons.Solid.Gear, - onClick = { navigate(Route.Settings.Main) }, - ) - } } } @@ -277,7 +234,7 @@ internal fun HomeSecondarySidebar( private fun SidebarItem( label: String, selected: Boolean, - icon: androidx.compose.ui.graphics.vector.ImageVector, + icon: ImageVector, onClick: () -> Unit, ) { NavigationDrawerItem( diff --git a/appleApp/ios/UI/FlareRoot.swift b/appleApp/ios/UI/FlareRoot.swift index ee1cd51c62..1638f18ff8 100644 --- a/appleApp/ios/UI/FlareRoot.swift +++ b/appleApp/ios/UI/FlareRoot.swift @@ -23,19 +23,6 @@ struct FlareRoot: View { @StateObject private var navigationModel = HomeNavigationModel() @State var selectedTab: String? @State private var reloginRoute: Route? - - private var singleColumnSecondaryRoutes: [Route] { - var routes: [Route] = [] - if case .success(let data) = onEnum(of: secondaryTabsPresenter.state.items) { - routes += data.data.cast(SecondaryTabsPresenter.Item.self).flatMap { item in - item.tabs.compactMap { route(for: $0) } - } - } - routes += SecondarySidebarStaticRoute.allCases - .filter { $0 != .agentHistory || aiAgentEnabledPresenter.state.enabled } - .map(\.route) - return routes - } var body: some View { StateView(state: homeTabsPresenter.state.tabs) { tabs in @@ -46,9 +33,7 @@ struct FlareRoot: View { tabs: items, selectedTab: $selectedTab, notificationCount: Int(notificationBadgePresenter.state.count), - navigationModel: navigationModel, - secondaryRoutes: singleColumnSecondaryRoutes, - usesTabView: true + navigationModel: navigationModel ) } else { TabView(selection: $selectedTab) { @@ -180,9 +165,7 @@ struct BackportFlareRoot: View { tabs: items, selectedTab: $selectedTab, notificationCount: Int(notificationBadgePresenter.state.count), - navigationModel: navigationModel, - secondaryRoutes: [], - usesTabView: false + navigationModel: navigationModel ) } else { TabView(selection: $selectedTab) { @@ -321,7 +304,7 @@ private struct SidebarRouteScreen: View { } } -private enum SecondarySidebarStaticRoute: CaseIterable { +enum SecondarySidebarStaticRoute: CaseIterable { case drafts case rssManagement case localHistory @@ -393,7 +376,7 @@ private enum SecondarySidebarStaticRoute: CaseIterable { private final class HomeNavigationModel: ObservableObject { @Published private var backStacks: [Route: [Route]] = [:] @Published private(set) var selectedTopLevelRoute: Route? - let navigator = RouterNavigator() + @Published var routeRequest: Route? func binding(for route: Route) -> Binding<[Route]> { Binding( @@ -412,16 +395,27 @@ private struct SingleColumnFlareRoot: View { @Binding var selectedTab: String? let notificationCount: Int @ObservedObject var navigationModel: HomeNavigationModel - let secondaryRoutes: [Route] - let usesTabView: Bool + @StateObject private var secondaryTabsPresenter = KotlinPresenter(presenter: SecondaryTabsPresenter()) + @StateObject private var aiAgentEnabledPresenter = KotlinPresenter(presenter: AiAgentEnabledPresenter()) @Environment(\.globalAppearance) private var globalAppearance private var activeTab: HomeTabsPresenterStateHomeTabs? { tabs.first { homeTabKey($0) == selectedTab } ?? tabs.first } - private var activeRoute: Route? { - navigationModel.selectedTopLevelRoute ?? activeTab.map(homeTabRoute) + private var accounts: [SecondaryTabsPresenter.Item] { + guard case .success(let data) = onEnum(of: secondaryTabsPresenter.state.items) else { + return [] + } + return data.data.cast(SecondaryTabsPresenter.Item.self) + } + + private var secondaryRoutes: [Route] { + accounts.flatMap { item in + item.tabs.compactMap { route(for: $0) } + } + SecondarySidebarStaticRoute.allCases + .filter { $0 != .agentHistory || aiAgentEnabledPresenter.state.enabled } + .map(\.route) } var body: some View { @@ -474,7 +468,7 @@ private struct SingleColumnFlareRoot: View { Spacer(minLength: 0) if !showRightSidebar { Button { - navigationModel.navigator.navigate(.secondaryMenu) + navigationModel.routeRequest = .secondaryMenu } label: { VStack(spacing: 4) { Image(fontAwesome: .ellipsis) @@ -499,35 +493,22 @@ private struct SingleColumnFlareRoot: View { Divider() HStack(spacing: 0) { - if #available(iOS 18.0, *), usesTabView { - SingleColumnTopLevelTabView( - tabs: tabs, - selectedTab: $selectedTab, - secondaryRoutes: secondaryRoutes, - showRightSidebar: showRightSidebar, - navigationModel: navigationModel - ) - } else if let activeRoute { - Router( - backStack: navigationModel.binding(for: activeRoute), - navigator: navigationModel.navigator - ) { onNavigate in - activeRoute.view( - onNavigate: onNavigate, - goBack: {}, - showsSecondaryMenu: !showRightSidebar - ) - } - .environment(\.horizontalSizeClass, .compact) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } + SingleColumnTopLevelTabView( + tabs: tabs, + selectedTab: $selectedTab, + secondaryRoutes: secondaryRoutes, + showRightSidebar: showRightSidebar, + navigationModel: navigationModel + ) if showRightSidebar { Divider() - SingleColumnSecondarySidebar { route in - navigationModel.selectTopLevel(route) - } - .frame(width: 320) + SingleColumnSecondarySidebar( + accounts: accounts, + aiAgentEnabled: aiAgentEnabledPresenter.state.enabled, + navigate: { navigationModel.selectTopLevel($0) } + ) + .frame(width: 320) } } } @@ -540,7 +521,6 @@ private struct SingleColumnFlareRoot: View { } } -@available(iOS 18.0, *) private struct SingleColumnTopLevelTabView: View { let tabs: [HomeTabsPresenterStateHomeTabs] @Binding var selectedTab: String? @@ -583,29 +563,32 @@ private struct SingleColumnTopLevelTabView: View { TabView(selection: selection) { ForEach(tabs, id: \.name) { tab in let route = homeTabRoute(tab) - Tab(value: route) { - topLevelContent(route) - } label: { - Label { - Text(homeTabTitle(tab)) - } icon: { - Image(fontAwesome: homeTabIcon(tab)) + topLevelContent(route) + .tabItem { + Label { + Text(homeTabTitle(tab)) + } icon: { + Image(fontAwesome: homeTabIcon(tab)) + } } - } + .tag(Optional(route)) } ForEach(availableSecondaryRoutes, id: \.self) { route in - Tab(value: route) { - topLevelContent(route) - } label: { - Label("more", systemImage: "ellipsis") - } - .tabPlacement(.sidebarOnly) + topLevelContent(route) + .tabItem { + Label("more", systemImage: "ellipsis") + } + .tag(Optional(route)) } } - .tabViewStyle(.tabBarOnly) - .introspect(.tabView, on: .iOS(.v18, .v26, .v27)) { tabBarController in - tabBarController.setTabBarHidden(true, animated: false) + .toolbar(.hidden, for: .tabBar) + .introspect(.tabView, on: .iOS(.v17, .v18, .v26, .v27)) { tabBarController in + if #available(iOS 18.0, *) { + tabBarController.setTabBarHidden(true, animated: false) + } else { + tabBarController.tabBar.isHidden = true + } } } @@ -613,7 +596,7 @@ private struct SingleColumnTopLevelTabView: View { private func topLevelContent(_ route: Route) -> some View { Router( backStack: navigationModel.binding(for: route), - navigator: activeRoute == route ? navigationModel.navigator : nil + routeRequest: activeRoute == route ? $navigationModel.routeRequest : nil ) { onNavigate in route.view( onNavigate: onNavigate, @@ -627,19 +610,12 @@ private struct SingleColumnTopLevelTabView: View { } private struct SingleColumnSecondarySidebar: View { + let accounts: [SecondaryTabsPresenter.Item] + let aiAgentEnabled: Bool let navigate: (Route) -> Void - @StateObject private var secondaryTabsPresenter = KotlinPresenter(presenter: SecondaryTabsPresenter()) @StateObject private var loggedInPresenter = KotlinPresenter(presenter: LoggedInPresenter()) - @StateObject private var aiAgentEnabledPresenter = KotlinPresenter(presenter: AiAgentEnabledPresenter()) @State private var searchQuery = "" - private var accounts: [SecondaryTabsPresenter.Item] { - guard case .success(let data) = onEnum(of: secondaryTabsPresenter.state.items) else { - return [] - } - return data.data.cast(SecondaryTabsPresenter.Item.self) - } - private var searchAccount: AccountType { accounts.first?.accountType ?? AccountType.Guest.shared } @@ -667,33 +643,14 @@ private struct SingleColumnSecondarySidebar: View { if !accounts.isEmpty { Section("account_management_title") { ForEach(Array(accounts.enumerated()), id: \.offset) { _, account in - DisclosureGroup { - ForEach(account.tabs, id: \.self) { tab in - if let route = route(for: tab) { - Button { - navigate(route) - } label: { - Label { - Text(tab.title.text) - } icon: { - Image(fontAwesome: tab.icon.fontAwesomeIcon) - } - } - .buttonStyle(.plain) - } - } - } label: { - StateView(state: account.user) { user in - UserCompatView(data: user) - } - } + SecondaryAccountDisclosure(account: account, onTabSelected: navigate) } } } Section { ForEach(SecondarySidebarStaticRoute.allCases.filter { route in - route != .agentHistory || aiAgentEnabledPresenter.state.enabled + route != .agentHistory || aiAgentEnabled }, id: \.self) { item in Button { navigate(item.route) diff --git a/appleApp/ios/UI/Route/Router.swift b/appleApp/ios/UI/Route/Router.swift index caa78eb0e0..126b1f05ba 100644 --- a/appleApp/ios/UI/Route/Router.swift +++ b/appleApp/ios/UI/Route/Router.swift @@ -10,7 +10,7 @@ struct Router: View { @ViewBuilder let root: (@escaping (Route) -> Void) -> Root @State private var ownedBackStack: [Route] = [] private let externalBackStack: Binding<[Route]>? - private let navigator: RouterNavigator? + private let routeRequest: Binding? @State private var sheet: Route? = nil @State private var cover: Route? = nil @State private var alertRoute: Route? = nil @@ -19,12 +19,12 @@ struct Router: View { init( backStack: Binding<[Route]>? = nil, - navigator: RouterNavigator? = nil, + routeRequest: Binding? = nil, @ViewBuilder root: @escaping (@escaping (Route) -> Void) -> Root ) { self.root = root self.externalBackStack = backStack - self.navigator = navigator + self.routeRequest = routeRequest let handler = DeepLinkHandler() self._deepLinkHandler = .init(wrappedValue: handler) self._deepLinkPresenter = .init(wrappedValue: .init(presenter: DeepLinkPresenter(onRoute: { [weak handler] deeplinkRoute in @@ -40,10 +40,6 @@ struct Router: View { externalBackStack ?? $ownedBackStack } - private var navigationRequests: AnyPublisher { - navigator?.requests.eraseToAnyPublisher() ?? Empty().eraseToAnyPublisher() - } - var body: some View { NavigationStack(path: backStack) { root({ route in @@ -103,8 +99,10 @@ struct Router: View { let targetURL = url.openInFlareTargetURL ?? url deepLinkPresenter.state.handle(url: targetURL.absoluteString) } - .onReceive(navigationRequests) { route in + .onChange(of: routeRequest?.wrappedValue) { _, route in + guard let route else { return } navigate(route: route) + routeRequest?.wrappedValue = nil } .onAppear { deepLinkHandler.onRoute = { route in @@ -173,15 +171,6 @@ struct Router: View { } } -@MainActor -final class RouterNavigator: ObservableObject { - fileprivate let requests = PassthroughSubject() - - func navigate(_ route: Route) { - requests.send(route) - } -} - class DeepLinkHandler : ObservableObject { var onRoute: ((Route) -> Void)? var onLink: ((String) -> Void)? diff --git a/appleApp/ios/UI/Screen/SecondaryTabsScreen.swift b/appleApp/ios/UI/Screen/SecondaryTabsScreen.swift index bbdf64a63a..2f0cd8045c 100644 --- a/appleApp/ios/UI/Screen/SecondaryTabsScreen.swift +++ b/appleApp/ios/UI/Screen/SecondaryTabsScreen.swift @@ -36,26 +36,7 @@ struct SecondaryTabsScreen: View { if !items.isEmpty { Section { ForEach(Array(items.enumerated()), id: \.offset) { _, item in - DisclosureGroup { - ForEach(item.tabs, id: \.self) { tab in - if let route = route(for: tab) { - Button { - onTabSelected(route) - } label: { - Label { - Text(tab.title.text) - } icon: { - Image(fontAwesome: tab.icon.fontAwesomeIcon) - } - } - .buttonStyle(.plain) - } - } - } label: { - StateView(state: item.user) { user in - UserCompatView(data: user) - } - } + SecondaryAccountDisclosure(account: item, onTabSelected: onTabSelected) } } header: { Text("account_management_title") @@ -64,44 +45,46 @@ struct SecondaryTabsScreen: View { } Section { - NavigationLink(value: Route.draftBox) { - Label { - Text("Drafts") - } icon: { - Image(fontAwesome: .penToSquare) - } - } - NavigationLink(value: Route.rssManagement) { - Label { - Text("settings_rss_management_title") - } icon: { - Image(fontAwesome: .squareRss) - } - } - NavigationLink(value: Route.localHostory) { - Label { - Text("local_history_title") - } icon: { - Image(fontAwesome: .clockRotateLeft) - } - } - if aiAgentEnabledPresenter.state.enabled { - NavigationLink(value: Route.agentHistory) { + ForEach(SecondarySidebarStaticRoute.allCases.filter { route in + route != .agentHistory || aiAgentEnabledPresenter.state.enabled + }, id: \.self) { item in + NavigationLink(value: item.route) { Label { - Text("agent_history_title") + Text(item.title) } icon: { - Image(fontAwesome: .robot) + Image(fontAwesome: item.icon) } } } - NavigationLink(value: Route.settings) { - Label { - Text("settings_title") - } icon: { - Image(fontAwesome: .gear) + } + } + } +} + +struct SecondaryAccountDisclosure: View { + let account: SecondaryTabsPresenter.Item + let onTabSelected: (Route) -> Void + + var body: some View { + DisclosureGroup { + ForEach(account.tabs, id: \.self) { tab in + if let route = route(for: tab) { + Button { + onTabSelected(route) + } label: { + Label { + Text(tab.title.text) + } icon: { + Image(fontAwesome: tab.icon.fontAwesomeIcon) + } } + .buttonStyle(.plain) } } + } label: { + StateView(state: account.user) { user in + UserCompatView(data: user) + } } } } diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusItems.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusItems.kt index a32d565b1d..2ba8ddb747 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusItems.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/LazyStatusItems.kt @@ -152,13 +152,12 @@ public fun LazyStaggeredGridScope.statusLoadingPlaceholders() { @Composable private fun TimelineAdaptiveCard( - modifier: Modifier = Modifier, index: Int, totalCount: Int, content: @Composable () -> Unit, ) { Box( - modifier = modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter, ) { AdaptiveCard( diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index 35ffea8a76..a056133a5e 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -32,11 +32,9 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.movableContentOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -62,11 +60,8 @@ import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.FlareScrollBar import dev.dimension.flare.ui.component.InAppNotificationComponent import dev.dimension.flare.ui.component.LocalGlobalAppearance -import dev.dimension.flare.ui.component.RichText import dev.dimension.flare.ui.component.platform.LocalWindowSizeClass import dev.dimension.flare.ui.component.platform.WindowSizeClass -import dev.dimension.flare.ui.component.toImageVector -import dev.dimension.flare.ui.model.asText import dev.dimension.flare.ui.model.map import dev.dimension.flare.ui.model.onError import dev.dimension.flare.ui.model.onLoading @@ -89,8 +84,6 @@ import io.github.composefluent.FluentTheme import io.github.composefluent.background.Layer import io.github.composefluent.component.Badge import io.github.composefluent.component.Button -import io.github.composefluent.component.CardExpanderItem -import io.github.composefluent.component.Expander import io.github.composefluent.component.FlyoutContainer import io.github.composefluent.component.FlyoutPlacement import io.github.composefluent.component.Icon @@ -182,65 +175,9 @@ internal fun WindowScope.FlareApp(backButtonState: NavigationBackButtonState) { ).verticalScroll(scrollableState), ) { state.items.onSuccess { items -> - items.forEach { item -> - item.user.onSuccess { user -> - var isSubMenuExpanded by remember { - mutableStateOf( - false, - ) - } - Expander( - expanded = isSubMenuExpanded, - onExpandedChanged = { - isSubMenuExpanded = it - }, - heading = { - RichText( - text = user.name, - maxLines = 1, - ) - }, - caption = { - Text( - text = user.handle.canonical, - maxLines = 1, - ) - }, - icon = { - AvatarComponent( - data = user.avatar, - modifier = - Modifier - .aspectRatio(1f), - size = 24.dp, - ) - }, - ) { - item.tabs.forEach { shortcut -> - CardExpanderItem( - onClick = { - state.navigate(shortcut) - isFlyoutVisible = false - }, - heading = { - dev.dimension.flare.ui.component.Text( - shortcut.title.asText(), - ) - }, - icon = { - FAIcon( - imageVector = shortcut.icon.toImageVector(), - contentDescription = null, - modifier = - Modifier.size( - 16.dp, - ), - ) - }, - ) - } - } - } + AccountShortcutList(items) { route -> + state.navigate(route) + isFlyoutVisible = false } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/HomeSecondarySidebar.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/HomeSecondarySidebar.kt index 083f1b636c..8da0c11633 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/HomeSecondarySidebar.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/HomeSecondarySidebar.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons @@ -44,6 +45,7 @@ import io.github.composefluent.component.SubtleButton import io.github.composefluent.component.Text import io.github.composefluent.component.TextField import kotlinx.collections.immutable.ImmutableList +import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource @Composable @@ -66,6 +68,16 @@ internal fun HomeSecondarySidebar( ) } val scrollState = rememberScrollState() + val sidebarRoutes = + buildList> { + add(Triple(Res.string.settings_draft_box_title, FontAwesomeIcons.Solid.PenToSquare, Route.DraftBox)) + add(Triple(Res.string.settings_rss_management_title, FontAwesomeIcons.Solid.SquareRss, Route.RssList)) + add(Triple(Res.string.settings_local_history_title, FontAwesomeIcons.Solid.ClockRotateLeft, Route.LocalCache)) + if (aiAgentEnabled) { + add(Triple(Res.string.settings_agent_history_title, FontAwesomeIcons.Solid.Robot, Route.AgentHistory)) + } + add(Triple(Res.string.home_settings, FontAwesomeIcons.Solid.Gear, Route.Settings)) + } FlareScrollBar(scrollState) { Column( @@ -105,69 +117,64 @@ internal fun HomeSecondarySidebar( ) } - accounts.forEach { account -> - var expanded by remember(account.accountType) { mutableStateOf(true) } - account.user.takeSuccess()?.let { user -> - Expander( - expanded = expanded, - onExpandedChanged = { expanded = it }, - heading = { - RichText(text = user.name, maxLines = 1) - }, - caption = { - Text(text = user.handle.canonical, maxLines = 1) - }, - icon = { - AvatarComponent(data = user.avatar, size = 24.dp) - }, - ) { - account.tabs.forEach { shortcut -> - val route = getDirection(shortcut) ?: return@forEach - CardExpanderItem( - onClick = { navigate(route) }, - heading = { - dev.dimension.flare.ui.component - .Text(shortcut.title.asText()) - }, - icon = { - FAIcon( - imageVector = shortcut.icon.toImageVector(), - contentDescription = null, - ) - }, - ) - } - } - } - } - - SidebarItem( - label = stringResource(Res.string.settings_draft_box_title), - icon = FontAwesomeIcons.Solid.PenToSquare, - onClick = { navigate(Route.DraftBox) }, - ) - SidebarItem( - label = stringResource(Res.string.settings_rss_management_title), - icon = FontAwesomeIcons.Solid.SquareRss, - onClick = { navigate(Route.RssList) }, + AccountShortcutList( + accounts = accounts, + initiallyExpanded = true, + navigate = navigate, ) - SidebarItem( - label = stringResource(Res.string.settings_local_history_title), - icon = FontAwesomeIcons.Solid.ClockRotateLeft, - onClick = { navigate(Route.LocalCache) }, - ) - if (aiAgentEnabled) { + + sidebarRoutes.forEach { (label, icon, route) -> SidebarItem( - label = stringResource(Res.string.settings_agent_history_title), - icon = FontAwesomeIcons.Solid.Robot, - onClick = { navigate(Route.AgentHistory) }, + label = stringResource(label), + icon = icon, + onClick = { navigate(route) }, ) } - SidebarItem( - label = stringResource(Res.string.home_settings), - icon = FontAwesomeIcons.Solid.Gear, - onClick = { navigate(Route.Settings) }, - ) + } + } +} + +@Composable +internal fun AccountShortcutList( + accounts: List, + initiallyExpanded: Boolean = false, + navigate: (Route) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + accounts.forEach { account -> + var expanded by remember(account.accountType) { mutableStateOf(initiallyExpanded) } + account.user.takeSuccess()?.let { user -> + Expander( + expanded = expanded, + onExpandedChanged = { expanded = it }, + heading = { + RichText(text = user.name, maxLines = 1) + }, + caption = { + Text(text = user.handle.canonical, maxLines = 1) + }, + icon = { + AvatarComponent(data = user.avatar, size = 24.dp) + }, + ) { + account.tabs.forEach { shortcut -> + val route = getDirection(shortcut) ?: return@forEach + CardExpanderItem( + onClick = { navigate(route) }, + heading = { + dev.dimension.flare.ui.component + .Text(shortcut.title.asText()) + }, + icon = { + FAIcon( + imageVector = shortcut.icon.toImageVector(), + contentDescription = null, + ) + }, + ) + } + } + } } } } @@ -175,7 +182,7 @@ internal fun HomeSecondarySidebar( @Composable private fun SidebarItem( label: String, - icon: androidx.compose.ui.graphics.vector.ImageVector, + icon: ImageVector, onClick: () -> Unit, ) { CardExpanderItem(