Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,69 @@ class SettingsRepository(context: Context) {
_logTracesInline.value = inline
}

// --- Navigation panels ---

/**
* Which panels the navigation container shows, in which order, and which are hidden.
*
* One delimited string rather than a set: `putStringSet` does not preserve order — the same
* fact `muted_updates` above relies on being harmless — and here the order is the whole point.
* Route keys rather than ordinals or class names, because R8 rewrites class names in a release
* build and an ordinal would silently mean a different panel the day a fifth one is added.
* Empty means "the catalogue as declared", which is what a fresh install has and what anyone
* who has never opened edit mode keeps. See NavPanels for the format.
*/
private val _navPanels = MutableStateFlow(prefs.getString("nav_panels", "") ?: "")
val navPanels: StateFlow<String> = _navPanels.asStateFlow()

fun setNavPanels(encoded: String) {
prefs.edit().putString("nav_panels", encoded).apply()
_navPanels.value = encoded
}

/**
* Whether the panels live on a draggable ball over the content instead of in a bar or a rail.
*
* Off by default: the bar is what every other app on the device puts there, and a reader who
* has not asked for anything else should not have to work out where their panels went. It is
* offered at all because the bar costs a strip of every screen for four items that are rarely
* touched, and on a small phone reading a log that strip is the expensive part.
*/
private val _floatingNav = MutableStateFlow(prefs.getBoolean("floating_nav", false))
val floatingNav: StateFlow<Boolean> = _floatingNav.asStateFlow()

fun setFloatingNav(enabled: Boolean) {
prefs.edit().putBoolean("floating_nav", enabled).apply()
_floatingNav.value = enabled
}

/**
* Where the floating ball was left: which side it snapped to, and how far down it sits as a
* fraction of the window height.
*
* No flow, for the same reason the ambience adjustments have none: written straight through
* from a gesture and read once when the ball is composed, so a StateFlow would recompose the
* very thing being dragged on every frame of the drag. Persisted rather than remembered because
* somebody who moved the ball out of the way of what they were reading has made a decision
* about their thumb, and the host process is killed often enough that anything held in memory
* would put the ball back over the content within the hour.
*
* The side is stored, not the x position: the ball always snaps to an edge, so a coordinate
* would be a lie the moment the window is a different width — which, unfoldable and in
* landscape, it routinely is.
*/
fun floatingNavAtEnd(): Boolean = prefs.getBoolean("floating_nav_at_end", true)

fun setFloatingNavAtEnd(atEnd: Boolean) {
prefs.edit().putBoolean("floating_nav_at_end", atEnd).apply()
}

fun floatingNavY(): Float = prefs.getFloat("floating_nav_y", 0.72f)

fun setFloatingNavY(fraction: Float) {
prefs.edit().putFloat("floating_nav_y", fraction).apply()
}

fun setThemeMode(mode: String) {
prefs.edit().putString("theme_mode", mode).apply()
_themeMode.value = mode
Expand Down
124 changes: 91 additions & 33 deletions manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt
Original file line number Diff line number Diff line change
@@ -1,37 +1,44 @@
package org.matrix.vector.manager.ui

import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import androidx.compose.material3.adaptive.navigationsuite.rememberNavigationSuiteScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import org.matrix.vector.manager.di.ServiceLocator
import org.matrix.vector.manager.ui.navigation.FrameworkUpdate
import org.matrix.vector.manager.ui.screens.update.FrameworkUpdateScreen
import org.matrix.vector.manager.ui.navigation.Canary
import org.matrix.vector.manager.ui.screens.canary.CanaryScreen
import org.matrix.vector.manager.ui.navigation.Troubleshoot
import org.matrix.vector.manager.ui.screens.report.TroubleshootScreen
import org.matrix.vector.manager.ui.navigation.DeepLink
import org.matrix.vector.manager.ui.navigation.FloatingPanelNav
import org.matrix.vector.manager.ui.navigation.LocalNavigator
import org.matrix.vector.manager.ui.navigation.Navigator
import org.matrix.vector.manager.ui.navigation.PanelBar
import org.matrix.vector.manager.ui.navigation.PanelEditDone
import org.matrix.vector.manager.ui.navigation.Scope
import org.matrix.vector.manager.ui.navigation.StoreDetail
import org.matrix.vector.manager.ui.navigation.CrashTrace
import org.matrix.vector.manager.ui.navigation.LogTrace
import org.matrix.vector.manager.ui.navigation.SystemStatus
import org.matrix.vector.manager.ui.navigation.Web
import org.matrix.vector.manager.ui.navigation.TOP_LEVEL_DESTINATIONS
import org.matrix.vector.manager.ui.navigation.TopLevelRoute
import org.matrix.vector.manager.ui.navigation.rememberNavigator
import org.matrix.vector.manager.ui.screens.home.HomeScreen
Expand All @@ -53,6 +60,11 @@ import org.matrix.vector.manager.ui.screens.web.WebScreen
* no longer lock itself to portrait or declare itself non-resizable on large screens, so the shell
* has to work unfolded and in landscape regardless. The scaffold also owns where that container
* sits, so the destinations below it are laid out beside or above it rather than under it.
*
* Which panels that container holds, in which order, is the reader's — see NavPanels — and there is
* a third arrangement it can take, a ball floating over the content with no container at all. The
* two are not independent: rearranging the panels needs something to rearrange, so edit mode always
* puts the container back for as long as it lasts.
*/
@Composable
fun VectorApp() {
Expand Down Expand Up @@ -82,7 +94,10 @@ fun VectorApp() {
}

CompositionLocalProvider(LocalNavigator provides navigator) {
// The bar shows only at the root of a tab. On a detail screen none of the four items is
val settings = ServiceLocator.settings
val floating by settings.floatingNav.collectAsStateWithLifecycle()
val editing = navigator.editingPanels
// The container shows only at the root of a panel. On a detail screen none of the items is
// the current destination, and a navigation bar highlighting nothing is worse than none.
val atRoot = !navigator.canGoBack

Expand All @@ -92,42 +107,85 @@ fun VectorApp() {
val suiteState = rememberNavigationSuiteScaffoldState()
LaunchedEffect(atRoot) { if (atRoot) suiteState.show() else suiteState.hide() }

// Computed rather than left to the scaffold's default, for two reasons: the floating style
// forces None, which is what actually removes the container instead of hiding it, and
// PanelBar has to be told which axis it is laying items along. Entering edit mode overrules
// the floating setting for as long as it lasts — there is nothing to rearrange otherwise.
val suiteType =
if (floating && !editing) NavigationSuiteType.None
else NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo())

NavigationSuiteScaffold(
navigationItems = {
// NavigationSuite's `when` over the type has no None branch and no else, so under
// None this slot is silently dropped along with the container. Skipping it here
// says so out loud rather than leaving a composable that never runs.
if (suiteType != NavigationSuiteType.None) {
PanelBar(
panels = navigator.panels,
current = navigator.currentTopLevel,
editing = editing,
suiteType = suiteType,
onSelect = { route -> navigator.switchTo(route) },
onEdit = { navigator.editingPanels = true },
onToggleHidden = { key, hidden -> navigator.setPanelHidden(key, hidden) },
onMove = { from, to -> navigator.movePanel(from, to) },
)
}
},
navigationSuiteType = suiteType,
state = suiteState,
navigationSuiteItems = {
TOP_LEVEL_DESTINATIONS.forEach { destination ->
item(
selected = navigator.currentTopLevel == destination.route,
onClick = { navigator.switchTo(destination.route) },
icon = { Icon(destination.icon, contentDescription = null) },
// The label doubles as the item's accessibility name, so the icon above
// carries no contentDescription of its own — otherwise TalkBack announces
// every selected tab twice.
label = { Text(stringResource(destination.labelRes)) },
primaryActionContent = {
if (editing) PanelEditDone(onDone = { navigator.editingPanels = false })
},
) {
Box(Modifier.fillMaxSize()) {
NavDisplay(
backStack = navigator.backStack,
onBack = { navigator.back() },
// Naming any decorator replaces NavDisplay's default, which is the
// saveable-state one alone, so it is repeated here; the scene-setup decorator
// NavDisplay applies internally is untouched. The ViewModel one is what this
// list is for: it scopes a ViewModelStore per entry, so opening the scope
// editor for a second module builds a second ViewModel instead of reusing the
// first (they would otherwise share one default key under the activity's
// store).
entryDecorators =
listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = entryProvider { registerRoutes(navigator) },
)
// Last child of the Box so it draws over the destination, and inside the app window
// rather than in one of its own: parasitically this app is com.android.shell, which
// must never ask for SYSTEM_ALERT_WINDOW. It follows the same rule the container
// does — present at the root of a panel, gone on a detail screen that has its own
// back affordance.
if (floating && !editing && atRoot) {
FloatingPanelNav(
panels = navigator.panels,
current = navigator.currentTopLevel,
onSelect = { route -> navigator.switchTo(route) },
)
}
}
},
) {
NavDisplay(
backStack = navigator.backStack,
onBack = { navigator.back() },
// Naming any decorator replaces NavDisplay's default, which is the saveable-state
// one alone, so it is repeated here; the scene-setup decorator NavDisplay applies
// internally is untouched. The ViewModel one is what this list is for: it scopes a
// ViewModelStore per entry, so opening the scope editor for a second module builds
// a second ViewModel instead of reusing the first (they would otherwise share one
// default key under the activity's store).
entryDecorators =
listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = entryProvider { registerRoutes(navigator) },
)
}

// After the scaffold on purpose. Back callbacks are dispatched last-registered-first and
// BackHandler registers from an effect, which run in composition order, so this one
// outranks the handler NavDisplay installs and edit mode ends before the stack is touched.
BackHandler(enabled = editing) { navigator.editingPanels = false }
}
}

/**
* Every destination, registered.
*
* All four panels keep their entry whether or not the reader has hidden them. A saved stack names
* its keys by class, and entryProvider throws for one it was never given, so dropping the
* registration of a hidden panel would turn a stale saved stack into a crash.
*/
private fun EntryProviderScope<NavKey>.registerRoutes(navigator: Navigator) {
entry<TopLevelRoute.Home> {
HomeScreen(
Expand Down
Loading
Loading