From eb2d9dd3c2713e02b080c913fb5a84d6ffd0bdc6 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:04:32 -0400 Subject: [PATCH 1/2] feat(ui): let the user choose and order the bottom bar destinations Completes the navigation rework. Home keeps the first slot, the next three are the user's to pick and arrange, and More still opens the drawer with everything in it. Defaults stay Members / Front history / Journals, so nobody's muscle memory moves without them asking. The editor lives in Settings > Appearance > Navigation bar, next to the theme, and applies changes immediately rather than hiding them behind a save button. It has two sections, "In the bar" and "Under More", with move up/down and remove/add controls. Ordering uses explicit buttons rather than drag-and-drop: the list is short, and buttons stay operable with a screen reader or switch access, which a long-press drag does not. Pins are per-device, like the theme override: which three things you want under your thumb is a fact about this phone, not about the account. They survive sign-out for the same reason. They are only reordered by the user, never by the app: a primary bar that rearranges itself by usage defeats the muscle memory it exists to serve, so there is no frequency-based shuffling here and shouldn't be later. Saved pins are read back by builds that may no longer have those destinations, so resolution treats them as untrusted: unknown routes are dropped rather than rendered as dead slots, duplicates collapse, and over-long lists are capped. Short lists are deliberately left short - padding them would mean removing something in the editor silently swapped in a default, leaving the bar and the editor disagreeing about what is pinned. Unit tests cover those cases and the reorder helper, plus a guard that every drawer route exists in Routes. Also drops the Board messages and Notifications icons from the Home top bar. Both were there because the bottom bar couldn't hold them; the drawer lists them now, so the top bar is back to just Settings. --- .../data/repository/PreferencesRepository.kt | 19 ++ .../java/systems/lupine/sheaf/ui/SheafApp.kt | 43 ++-- .../lupine/sheaf/ui/home/HomeScreen.kt | 19 +- .../lupine/sheaf/ui/navigation/AppDrawer.kt | 111 ++++++--- .../sheaf/ui/navigation/NavPinsScreen.kt | 214 ++++++++++++++++++ .../sheaf/ui/navigation/NavPinsViewModel.kt | 43 ++++ .../ui/settings/SettingsCategoryScreens.kt | 9 + .../sheaf/ui/navigation/ResolvePinsTest.kt | 103 +++++++++ 8 files changed, 490 insertions(+), 71 deletions(-) create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/navigation/NavPinsScreen.kt create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/navigation/NavPinsViewModel.kt create mode 100644 sheaf/app/src/test/java/systems/lupine/sheaf/ui/navigation/ResolvePinsTest.kt diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/repository/PreferencesRepository.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/repository/PreferencesRepository.kt index 2a63cfb..ba843a3 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/repository/PreferencesRepository.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/repository/PreferencesRepository.kt @@ -79,6 +79,11 @@ class PreferencesRepository @Inject constructor( // account; "auto" = pin this device to its own clock; else an IANA zone. val KEY_ACCOUNT_TIMEZONE = stringPreferencesKey("account_timezone") val KEY_TIMEZONE_OVERRIDE = stringPreferencesKey("timezone_override") + // Which destinations the user pinned to the bottom bar, in order, as + // newline-separated routes. Per-device like the theme override: which + // three things you want under your thumb is an ergonomics choice about + // this phone, not an account setting. Absent = the seeded defaults. + val KEY_NAV_PINS = stringPreferencesKey("nav_pins") } val baseUrl: Flow = context.dataStore.data.map { it[KEY_BASE_URL] } @@ -101,6 +106,11 @@ class PreferencesRepository @Inject constructor( val historyPageSize: Flow = context.dataStore.data.map { it[KEY_HISTORY_PAGE_SIZE] ?: 50 } val accountTimezone: Flow = context.dataStore.data.map { it[KEY_ACCOUNT_TIMEZONE] } val timezoneOverride: Flow = context.dataStore.data.map { it[KEY_TIMEZONE_OVERRIDE] } + // null means "never chosen" (the nav layer seeds its defaults); an empty + // list is a real choice to pin nothing, and is kept distinct from it. + val navPins: Flow?> = context.dataStore.data.map { prefs -> + prefs[KEY_NAV_PINS]?.lines()?.filter { it.isNotBlank() } + } suspend fun saveBaseUrl(url: String) { context.dataStore.edit { it[KEY_BASE_URL] = normalizeBaseUrl(url) } @@ -198,6 +208,15 @@ class PreferencesRepository @Inject constructor( context.dataStore.edit { it[KEY_HISTORY_PAGE_SIZE] = size } } + suspend fun saveNavPins(routes: List) { + context.dataStore.edit { it[KEY_NAV_PINS] = routes.joinToString("\n") } + } + + /** Forget the user's pins so the bar falls back to the seeded defaults. */ + suspend fun clearNavPins() { + context.dataStore.edit { it.remove(KEY_NAV_PINS) } + } + suspend fun clearTokens() { context.dataStore.edit { it.remove(KEY_ACCESS_TOKEN) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt index ca98f93..fb0d333 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt @@ -39,7 +39,10 @@ import systems.lupine.sheaf.ui.journals.JournalDetailScreen import systems.lupine.sheaf.ui.journals.JournalsScreen import systems.lupine.sheaf.ui.home.HomeScreen import systems.lupine.sheaf.ui.navigation.AppDrawerContent +import systems.lupine.sheaf.ui.navigation.NavPinsScreen +import systems.lupine.sheaf.ui.navigation.NavPinsViewModel import systems.lupine.sheaf.ui.navigation.drawerRoutes +import systems.lupine.sheaf.ui.navigation.homeDest import systems.lupine.sheaf.ui.members.MemberDetailScreen import systems.lupine.sheaf.ui.members.MemberProfileScreen import systems.lupine.sheaf.ui.members.MembersScreen @@ -114,6 +117,7 @@ object Routes { const val SETTINGS_ACCOUNT = "settings/account" const val SETTINGS_ADMIN_ACTIVITY = "settings/account/admin-activity" const val SETTINGS_APPEARANCE = "settings/appearance" + const val SETTINGS_NAV_BAR = "settings/appearance/nav-bar" const val SETTINGS_NOTIFICATIONS = "settings/notifications" const val SETTINGS_SERVER = "settings/server" const val SETTINGS_SYSTEM = "settings/sys" @@ -161,23 +165,11 @@ private val MAX_CONTENT_WIDTH = 840.dp // it gets (so capping it would just cost columns). private val FULL_BLEED_ROUTES = setOf(Routes.RELATIONSHIP_GRAPH, Routes.HOME) -// The fast path, not the whole app: Home plus three destinations, with a -// "More" entry alongside them that opens the drawer. Everything else (Polls, -// Analytics, Reminders, Relationships, Files, ...) lives in the drawer, which -// is the complete list. -val topLevelDestinations = listOf( - TopLevelDest(Routes.HOME, "Home", Icons.Filled.Home, Icons.Outlined.Home), - TopLevelDest(Routes.PEOPLE, "Members", Icons.Filled.People, Icons.Outlined.People), - TopLevelDest(Routes.HISTORY, "History", Icons.Filled.History, Icons.Outlined.History), - TopLevelDest(Routes.JOURNALS, "Journals", Icons.AutoMirrored.Filled.MenuBook, Icons.AutoMirrored.Outlined.MenuBook), -) - -// Destinations that keep the bar/rail on screen: the bar's own four, plus -// everything reachable from the drawer. Without the drawer routes here, -// stepping to a drawer destination would drop the app chrome and strand the -// user on a screen with no way back but the system back gesture. -private val chromeRoutes: Set = - topLevelDestinations.mapTo(mutableSetOf()) { it.route } + drawerRoutes +// Destinations that keep the bar/rail on screen: everything the drawer can +// reach, which is a superset of whatever is currently pinned to the bar. +// Without this, stepping to a drawer destination would drop the app chrome and +// strand the user on a screen with no way back but the system back gesture. +private val chromeRoutes: Set = drawerRoutes // ── Root composable ─────────────────────────────────────────────────────────── @@ -185,6 +177,7 @@ private val chromeRoutes: Set = fun SheafApp( pendingRedemption: PendingRedemptionHolder, authViewModel: AuthViewModel = hiltViewModel(), + navPinsViewModel: NavPinsViewModel = hiltViewModel(), ) { val isLoggedIn by authViewModel.isLoggedIn.collectAsState() val pendingRedeem by pendingRedemption.pending.collectAsState() @@ -248,6 +241,10 @@ fun SheafApp( else -> NavigationSuiteType.NavigationBar } + // Home owns the first slot always; the rest are the user's pins. + val pinned by navPinsViewModel.pins.collectAsState() + val barDestinations = remember(pinned) { listOf(homeDest) + pinned } + val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() // Switching top-level destination is a "start over here" move, not a step @@ -280,14 +277,14 @@ fun SheafApp( NavigationSuiteScaffold( layoutType = navSuiteType, navigationSuiteItems = { - topLevelDestinations.forEach { dest -> + barDestinations.forEach { dest -> val selected = currentDest?.hierarchy?.any { it.route == dest.route } == true item( selected = selected, onClick = { goTo(dest.route) }, icon = { Icon( - if (selected) dest.selectedIcon else dest.unselectedIcon, + if (selected) dest.selectedIcon else dest.icon, contentDescription = dest.label, ) }, @@ -299,7 +296,7 @@ fun SheafApp( // still shows where you are. val onDrawerDest = currentRoute != null && currentRoute in drawerRoutes && - topLevelDestinations.none { it.route == currentRoute } + barDestinations.none { it.route == currentRoute } item( selected = onDrawerDest, onClick = { scope.launch { drawerState.open() } }, @@ -352,8 +349,6 @@ fun SheafApp( onNavigateToSystemSafety = { navController.navigate(Routes.SYSTEM_SAFETY) }, onNavigateToRetention = { navController.navigate(Routes.SETTINGS_RETENTION) }, onNavigateToSettings = { navController.navigate(Routes.SETTINGS) }, - onNavigateToMessages = { navController.navigate(Routes.MESSAGES) }, - onNavigateToNotifications = { navController.navigate(Routes.SETTINGS_NOTIFICATIONS) }, ) } composable(Routes.PEOPLE) { @@ -472,8 +467,12 @@ fun SheafApp( composable(Routes.SETTINGS_APPEARANCE) { systems.lupine.sheaf.ui.settings.AppearanceSettingsScreen( onNavigateUp = { navController.navigateUp() }, + onNavigateToNavBar = { navController.navigate(Routes.SETTINGS_NAV_BAR) }, ) } + composable(Routes.SETTINGS_NAV_BAR) { + NavPinsScreen(onNavigateUp = { navController.navigateUp() }) + } composable(Routes.SETTINGS_NOTIFICATIONS) { systems.lupine.sheaf.ui.settings.NotificationSettingsScreen( onNavigateUp = { navController.navigateUp() }, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt index 7106c9e..0375b47 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt @@ -55,8 +55,6 @@ fun HomeScreen( onNavigateToSystemSafety: () -> Unit, onNavigateToRetention: () -> Unit, onNavigateToSettings: () -> Unit, - onNavigateToMessages: () -> Unit, - onNavigateToNotifications: () -> Unit, viewModel: HomeViewModel = hiltViewModel(), authViewModel: AuthViewModel = hiltViewModel(), ) { @@ -93,20 +91,9 @@ fun HomeScreen( ) }, actions = { - IconButton(onClick = onNavigateToMessages) { - Icon(Icons.Outlined.Forum, contentDescription = "Board messages") - } - // Notifications hub on the top bar: same parity with - // web's sidebar (notifications is a first-class entry, - // not buried two taps into Settings). One tap from - // Home reaches owned channels, your subscriptions, - // your devices, and reminders. - IconButton(onClick = onNavigateToNotifications) { - Icon( - Icons.Outlined.Notifications, - contentDescription = "Notifications", - ) - } + // Board messages and Notifications used to sit here too. + // The drawer lists both now, so the top bar doesn't need to + // carry the overflow that the bottom bar couldn't hold. IconButton(onClick = onNavigateToSettings) { Icon(Icons.Default.Settings, contentDescription = "Settings") } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/navigation/AppDrawer.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/navigation/AppDrawer.kt index d6663d9..583b857 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/navigation/AppDrawer.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/navigation/AppDrawer.kt @@ -1,25 +1,16 @@ package systems.lupine.sheaf.ui.navigation +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.automirrored.outlined.MenuBook -import androidx.compose.material.icons.outlined.Alarm -import androidx.compose.material.icons.outlined.Folder -import androidx.compose.material.icons.outlined.FolderOpen -import androidx.compose.material.icons.outlined.Forum -import androidx.compose.material.icons.outlined.HelpOutline -import androidx.compose.material.icons.outlined.History -import androidx.compose.material.icons.outlined.Home -import androidx.compose.material.icons.outlined.HowToVote -import androidx.compose.material.icons.outlined.Hub -import androidx.compose.material.icons.outlined.Insights -import androidx.compose.material.icons.outlined.Notifications -import androidx.compose.material.icons.outlined.People -import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -33,11 +24,16 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import systems.lupine.sheaf.ui.Routes -/** A single destination row in the navigation drawer. */ +/** + * A destination the user can navigate to from the drawer, and (Home aside) pin + * to the bottom bar. [selectedIcon] is the filled variant shown when the bar + * slot is the current destination; it falls back to the outlined one. + */ data class DrawerDest( val route: String, val label: String, val icon: ImageVector, + val selectedIcon: ImageVector = icon, ) /** A titled cluster of drawer rows. A null title renders with no header. */ @@ -46,6 +42,9 @@ data class DrawerGroup( val items: List, ) +/** Home is the fixed first slot: not movable, not removable, always present. */ +val homeDest = DrawerDest(Routes.HOME, "Home", Icons.Outlined.Home, Icons.Filled.Home) + /** * The complete destination list, grouped. This is the Android expression of * web's sidebar: the bottom bar / rail is only a fast path to a few of these, @@ -57,52 +56,100 @@ data class DrawerGroup( val drawerGroups: List = listOf( DrawerGroup( title = null, - items = listOf( - DrawerDest(Routes.HOME, "Home", Icons.Outlined.Home), - ), + items = listOf(homeDest), ), DrawerGroup( title = "Tracking", items = listOf( - DrawerDest(Routes.PEOPLE, "Members", Icons.Outlined.People), - DrawerDest(Routes.GROUPS, "Groups", Icons.Outlined.FolderOpen), - DrawerDest(Routes.HISTORY, "Front history", Icons.Outlined.History), - DrawerDest(Routes.ANALYTICS, "Analytics", Icons.Outlined.Insights), + DrawerDest(Routes.PEOPLE, "Members", Icons.Outlined.People, Icons.Filled.People), + DrawerDest(Routes.GROUPS, "Groups", Icons.Outlined.FolderOpen, Icons.Filled.FolderOpen), + DrawerDest(Routes.HISTORY, "Front history", Icons.Outlined.History, Icons.Filled.History), + DrawerDest(Routes.ANALYTICS, "Analytics", Icons.Outlined.Insights, Icons.Filled.Insights), ), ), DrawerGroup( title = "Writing", items = listOf( - DrawerDest(Routes.JOURNALS, "Journals", Icons.AutoMirrored.Outlined.MenuBook), - DrawerDest(Routes.MESSAGES, "Board messages", Icons.Outlined.Forum), + DrawerDest( + Routes.JOURNALS, + "Journals", + Icons.AutoMirrored.Outlined.MenuBook, + Icons.AutoMirrored.Filled.MenuBook, + ), + DrawerDest(Routes.MESSAGES, "Board messages", Icons.Outlined.Forum, Icons.Filled.Forum), ), ), DrawerGroup( title = "Engage", items = listOf( - DrawerDest(Routes.POLLS, "Polls", Icons.Outlined.HowToVote), - DrawerDest(Routes.NOTIFICATIONS_REMINDERS, "Reminders", Icons.Outlined.Alarm), + DrawerDest(Routes.POLLS, "Polls", Icons.Outlined.HowToVote, Icons.Filled.HowToVote), + DrawerDest( + Routes.NOTIFICATIONS_REMINDERS, + "Reminders", + Icons.Outlined.Alarm, + Icons.Filled.Alarm, + ), ), ), DrawerGroup( title = "System", items = listOf( - DrawerDest(Routes.RELATIONSHIPS, "Relationships", Icons.Outlined.Hub), - DrawerDest(Routes.FILES, "Files", Icons.Outlined.Folder), + DrawerDest(Routes.RELATIONSHIPS, "Relationships", Icons.Outlined.Hub, Icons.Filled.Hub), + DrawerDest(Routes.FILES, "Files", Icons.Outlined.Folder, Icons.Filled.Folder), ), ), DrawerGroup( title = null, items = listOf( - DrawerDest(Routes.SETTINGS_NOTIFICATIONS, "Notifications", Icons.Outlined.Notifications), + DrawerDest( + Routes.SETTINGS_NOTIFICATIONS, + "Notifications", + Icons.Outlined.Notifications, + Icons.Filled.Notifications, + ), DrawerDest(Routes.SUPPORT, "Support", Icons.Outlined.HelpOutline), - DrawerDest(Routes.SETTINGS, "Settings", Icons.Outlined.Settings), + DrawerDest(Routes.SETTINGS, "Settings", Icons.Outlined.Settings, Icons.Filled.Settings), ), ), ) +/** Every destination, flattened. */ +val allDests: List = drawerGroups.flatMap { it.items } + /** Every route the drawer can reach, for chrome / selection decisions. */ -val drawerRoutes: Set = drawerGroups.flatMap { group -> group.items.map { it.route } }.toSet() +val drawerRoutes: Set = allDests.mapTo(mutableSetOf()) { it.route } + +/** Everything the user may pin. Home is excluded: it owns the first slot. */ +val pinnableDests: List = allDests.filter { it.route != Routes.HOME } + +/** How many slots sit between Home and the More entry. */ +const val PIN_SLOTS = 3 + +/** What a fresh install pins, before the user says otherwise. */ +val DEFAULT_PINS: List = listOf(Routes.PEOPLE, Routes.HISTORY, Routes.JOURNALS) + +/** + * Turn saved pin routes into the destinations the bar should show. + * + * [saved] is null when the user has never chosen, which seeds [DEFAULT_PINS]; + * an empty list is a real choice (bar of just Home and More) and is honoured as + * one. Short lists are left short rather than topped up: padding would mean + * unpinning something in the editor silently put a different destination in its + * place, so the bar and the editor would disagree about what is pinned. + * + * Saved pins outlive the build that wrote them, so the routes are treated as + * untrusted: unknown ones (a destination renamed or dropped in a later version) + * are discarded rather than rendered as a dead slot, and duplicates collapse. + * Home is never included; it is prepended by the caller. + */ +fun resolvePins(saved: List?): List { + val byRoute = pinnableDests.associateBy { it.route } + val chosen = LinkedHashMap() + (saved ?: DEFAULT_PINS).forEach { route -> + byRoute[route]?.let { chosen.putIfAbsent(route, it) } + } + return chosen.values.take(PIN_SLOTS) +} /** * Drawer body: the grouped destination list. Scrolls, because the list is @@ -114,9 +161,7 @@ fun AppDrawerContent( onNavigate: (String) -> Unit, ) { ModalDrawerSheet { - androidx.compose.foundation.layout.Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - ) { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Text( text = "Sheaf", style = MaterialTheme.typography.titleLarge, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/navigation/NavPinsScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/navigation/NavPinsScreen.kt new file mode 100644 index 0000000..365ceb2 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/navigation/NavPinsScreen.kt @@ -0,0 +1,214 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) + +package systems.lupine.sheaf.ui.navigation + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.outlined.AddCircleOutline +import androidx.compose.material.icons.outlined.ArrowDownward +import androidx.compose.material.icons.outlined.ArrowUpward +import androidx.compose.material.icons.outlined.RemoveCircleOutline +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import systems.lupine.sheaf.ui.components.SheafTopAppBar + +/** + * Choose which destinations sit in the bottom bar, and in what order. Changes + * apply immediately, like the theme picker; there is no save button to forget + * to press. + * + * Ordering is done with explicit move buttons rather than drag-and-drop: the + * list is short, and buttons stay operable with a screen reader or switch + * access, which a long-press drag does not. + */ +@Composable +fun NavPinsScreen( + onNavigateUp: () -> Unit, + viewModel: NavPinsViewModel = hiltViewModel(), +) { + val pinned by viewModel.pins.collectAsState() + val pinnedRoutes = pinned.map { it.route } + val available = pinnableDests.filter { it.route !in pinnedRoutes } + val atCapacity = pinned.size >= PIN_SLOTS + + Scaffold( + contentWindowInsets = WindowInsets(0), + topBar = { + SheafTopAppBar( + title = { Text("Navigation bar") }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = "Up to $PIN_SLOTS destinations sit in the bar beside Home. " + + "Everything else stays one tap away under More.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + ) + + SectionHeader("In the bar") + + // Home is shown so the bar's real shape is visible, but it has no + // controls: it always holds the first slot. + ListItem( + headlineContent = { Text(homeDest.label) }, + supportingContent = { Text("Always first") }, + leadingContent = { Icon(homeDest.icon, contentDescription = null) }, + trailingContent = { + Icon( + Icons.Filled.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + }, + colors = ListItemDefaults.colors( + headlineColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + + if (pinned.isEmpty()) { + Text( + text = "Nothing pinned. The bar shows Home and More only.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + ) + } + + pinned.forEachIndexed { index, dest -> + HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) + ListItem( + headlineContent = { Text(dest.label) }, + supportingContent = { Text("Slot ${index + 2}") }, + leadingContent = { Icon(dest.icon, contentDescription = null) }, + trailingContent = { + Row { + IconButton( + onClick = { viewModel.setPins(pinnedRoutes.moved(index, index - 1)) }, + enabled = index > 0, + ) { + Icon( + Icons.Outlined.ArrowUpward, + contentDescription = "Move ${dest.label} up", + ) + } + IconButton( + onClick = { viewModel.setPins(pinnedRoutes.moved(index, index + 1)) }, + enabled = index < pinned.lastIndex, + ) { + Icon( + Icons.Outlined.ArrowDownward, + contentDescription = "Move ${dest.label} down", + ) + } + IconButton(onClick = { viewModel.setPins(pinnedRoutes - dest.route) }) { + Icon( + Icons.Outlined.RemoveCircleOutline, + contentDescription = "Remove ${dest.label} from the bar", + ) + } + } + }, + ) + } + + HorizontalDivider() + SectionHeader(if (atCapacity) "Under More (bar is full)" else "Under More") + + available.forEach { dest -> + Surface( + onClick = { viewModel.setPins(pinnedRoutes + dest.route) }, + enabled = !atCapacity, + modifier = Modifier.fillMaxWidth(), + ) { + ListItem( + headlineContent = { Text(dest.label) }, + leadingContent = { Icon(dest.icon, contentDescription = null) }, + trailingContent = { + // Kept visible but inert at capacity, so the reason + // nothing happens is the greyed-out state rather + // than a control that vanished. + Icon( + Icons.Outlined.AddCircleOutline, + contentDescription = "Pin ${dest.label} to the bar", + tint = if (atCapacity) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.primary, + ) + }, + colors = ListItemDefaults.colors( + headlineColor = if (atCapacity) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurface, + ), + ) + } + HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) + } + + TextButton( + onClick = { viewModel.resetToDefaults() }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Text("Reset to defaults") + } + } + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 4.dp), + ) +} + +/** + * Move the item at [from] to [to], shifting the rest along. Out-of-range + * targets return the list untouched, so callers can wire up buttons at the ends + * of the list without special-casing them. + */ +internal fun List.moved(from: Int, to: Int): List { + if (from !in indices || to !in indices || from == to) return this + val out = toMutableList() + out.add(to, out.removeAt(from)) + return out +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/navigation/NavPinsViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/navigation/NavPinsViewModel.kt new file mode 100644 index 0000000..de518b7 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/navigation/NavPinsViewModel.kt @@ -0,0 +1,43 @@ +package systems.lupine.sheaf.ui.navigation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import systems.lupine.sheaf.data.repository.PreferencesRepository +import javax.inject.Inject + +/** + * The user's bottom-bar pins. Read by the app shell to build the bar, and by + * the edit screen to change it. + * + * The flow emits resolved destinations rather than raw routes so every consumer + * gets the same fallback behaviour (see [resolvePins]); the initial value is the + * defaults, so the bar renders its final shape on the first frame rather than + * flickering from empty once DataStore reports back. + */ +@HiltViewModel +class NavPinsViewModel @Inject constructor( + private val prefs: PreferencesRepository, +) : ViewModel() { + + val pins: StateFlow> = prefs.navPins + .map { resolvePins(it) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = resolvePins(null), + ) + + fun setPins(routes: List) { + viewModelScope.launch { prefs.saveNavPins(routes) } + } + + fun resetToDefaults() { + viewModelScope.launch { prefs.clearNavPins() } + } +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsCategoryScreens.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsCategoryScreens.kt index f8a2375..076e520 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsCategoryScreens.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsCategoryScreens.kt @@ -34,6 +34,7 @@ import androidx.compose.material.icons.outlined.AddCircle import androidx.compose.material.icons.outlined.Alarm import androidx.compose.material.icons.outlined.BrightnessAuto import androidx.compose.material.icons.outlined.DarkMode +import androidx.compose.material.icons.outlined.Dashboard import androidx.compose.material.icons.outlined.DeleteForever import androidx.compose.material.icons.outlined.DeleteSweep import androidx.compose.material.icons.outlined.Devices @@ -120,6 +121,7 @@ private fun CategoryScaffold( @Composable fun AppearanceSettingsScreen( onNavigateUp: () -> Unit, + onNavigateToNavBar: () -> Unit, viewModel: SettingsViewModel = hiltViewModel(), ) { val themeMode by viewModel.themeMode.collectAsState() @@ -166,6 +168,13 @@ fun AppearanceSettingsScreen( onChange = { viewModel.setThemeSynced(it) }, ) HorizontalDivider() + SettingItem( + icon = Icons.Outlined.Dashboard, + title = "Navigation bar", + subtitle = "Choose which destinations sit in the bar", + onClick = onNavigateToNavBar, + ) + HorizontalDivider() TimezoneSection() } } diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/ui/navigation/ResolvePinsTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/navigation/ResolvePinsTest.kt new file mode 100644 index 0000000..8e1cd39 --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/navigation/ResolvePinsTest.kt @@ -0,0 +1,103 @@ +package systems.lupine.sheaf.ui.navigation + +import systems.lupine.sheaf.ui.Routes +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Pins are persisted user data read back by later builds, so resolution has to + * cope with routes that no longer exist, duplicates, and over-long lists + * without producing a dead or ragged bar. + */ +class ResolvePinsTest { + + private fun routesOf(saved: List?) = resolvePins(saved).map { it.route } + + @Test fun `never set seeds the defaults`() { + assertEquals(DEFAULT_PINS, routesOf(null)) + } + + @Test fun `empty list is a real choice and pins nothing`() { + // Distinct from null: the user unpinned everything, so the bar is just + // Home and More rather than silently reverting to the defaults. + assertEquals(emptyList(), routesOf(emptyList())) + } + + @Test fun `saved pins are honoured in order`() { + val saved = listOf(Routes.POLLS, Routes.ANALYTICS, Routes.MESSAGES) + assertEquals(saved, routesOf(saved)) + } + + @Test fun `unknown routes are dropped rather than rendered as dead slots`() { + val saved = listOf(Routes.POLLS, "settings/relationships", Routes.MESSAGES) + assertEquals(listOf(Routes.POLLS, Routes.MESSAGES), routesOf(saved)) + } + + @Test fun `a fully stale list falls back to nothing pinned, not to junk`() { + assertEquals(emptyList(), routesOf(listOf("nope", "also/nope"))) + } + + @Test fun `duplicates collapse to a single slot`() { + val saved = listOf(Routes.POLLS, Routes.POLLS, Routes.MESSAGES) + assertEquals(listOf(Routes.POLLS, Routes.MESSAGES), routesOf(saved)) + } + + @Test fun `over-long lists are capped at the slot count`() { + val saved = listOf(Routes.POLLS, Routes.ANALYTICS, Routes.MESSAGES, Routes.FILES) + assertEquals(PIN_SLOTS, routesOf(saved).size) + assertTrue(Routes.FILES !in routesOf(saved)) + } + + @Test fun `short lists stay short instead of being topped up`() { + // Padding would mean unpinning something in the editor silently swapped + // in a default, so the bar and the editor would disagree. + assertEquals(listOf(Routes.POLLS), routesOf(listOf(Routes.POLLS))) + } + + @Test fun `home can never be pinned into a second slot`() { + assertTrue(Routes.HOME !in routesOf(listOf(Routes.HOME, Routes.POLLS))) + assertTrue(pinnableDests.none { it.route == Routes.HOME }) + } + + @Test fun `defaults are themselves pinnable routes`() { + // Guards against a rename landing in Routes but not in DEFAULT_PINS, + // which would silently give new installs an empty bar. + val pinnable = pinnableDests.map { it.route }.toSet() + DEFAULT_PINS.forEach { assertTrue(it in pinnable, "default pin $it is not pinnable") } + assertEquals(PIN_SLOTS, DEFAULT_PINS.size) + } + + @Test fun `moving an item up swaps it with its predecessor`() { + assertEquals(listOf("b", "a", "c"), listOf("a", "b", "c").moved(1, 0)) + } + + @Test fun `moving an item down swaps it with its successor`() { + assertEquals(listOf("a", "c", "b"), listOf("a", "b", "c").moved(1, 2)) + } + + @Test fun `moving past either end is a no-op rather than a crash`() { + // The end-of-list buttons are disabled, but the helper is what makes + // that safe rather than merely tidy. + val list = listOf("a", "b", "c") + assertEquals(list, list.moved(0, -1)) + assertEquals(list, list.moved(2, 3)) + assertEquals(list, list.moved(1, 1)) + } + + @Test fun `moving preserves every item`() { + val list = listOf("a", "b", "c") + assertEquals(list.toSet(), list.moved(0, 2).toSet()) + assertEquals(list.size, list.moved(0, 2).size) + } + + @Test fun `every drawer destination resolves to a registered route`() { + // The drawer is the complete list; a typo'd route here would be a row + // that navigates nowhere. + val known = Routes::class.java.declaredFields + .filter { it.type == String::class.java } + .mapNotNull { it.isAccessible = true; it.get(Routes) as? String } + .toSet() + allDests.forEach { assertTrue(it.route in known, "drawer route ${it.route} is not in Routes") } + } +} From 1ba4a0dc928894b91a2b4980fa5b13c01e560297 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:04:32 -0400 Subject: [PATCH 2/2] docs(ui): make Settings category subtitles list what is actually inside The subtitles had drifted from the screens they describe: Appearance said "Theme, timezone" while also holding the palette picker and now the navigation bar editor, System said "Custom fields" while holding tags and archived members too, and Notifications named only the two device toggles while holding subscriptions, channels and devices above them. Since the subtitle is what people scan to decide which category to open, each now names its contents. Safety keeps its re-auth level, which is real state worth surfacing, and gains a mention of revision retention. --- .../sheaf/ui/settings/SettingsScreen.kt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt index 163435c..9d2d676 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt @@ -212,21 +212,21 @@ fun SettingsScreen( SettingItem( icon = Icons.Outlined.Person, title = "Account", - subtitle = "Email, two-factor auth, API keys, sessions", + subtitle = "Two-factor auth, API keys, sessions, watch pairing", onClick = onNavigateToAccount, ) HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) SettingItem( icon = Icons.Outlined.Palette, title = "Appearance", - subtitle = "Theme, timezone", + subtitle = "Theme, palette, navigation bar, timezone", onClick = onNavigateToAppearance, ) HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) SettingItem( icon = Icons.AutoMirrored.Outlined.List, title = "System", - subtitle = "Custom fields", + subtitle = "Tags, custom fields, archived members", onClick = onNavigateToSystem, ) HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) @@ -240,14 +240,14 @@ fun SettingsScreen( SettingItem( icon = Icons.Outlined.Folder, title = "Data", - subtitle = "Files, export, import", + subtitle = "Storage, files, export, import", onClick = onNavigateToData, ) HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) SettingItem( icon = Icons.Outlined.Notifications, title = "Notifications & Lock", - subtitle = "Fronting notification, app lock", + subtitle = "Subscriptions, channels, devices, fronting notification, app lock", onClick = onNavigateToNotifications, ) HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) @@ -549,11 +549,11 @@ internal fun formatBytes(bytes: Long): String = when { } internal fun formatSafetySubtitle(level: String?): String = when (level) { - "none" -> "Re-auth: none" - "password" -> "Re-auth: password" - "totp" -> "Re-auth: authenticator code" - "both" -> "Re-auth: password + authenticator" - else -> "Grace period and re-auth for destructive actions" + "none" -> "Revision retention \u00b7 re-auth: none" + "password" -> "Revision retention \u00b7 re-auth: password" + "totp" -> "Revision retention \u00b7 re-auth: authenticator code" + "both" -> "Revision retention \u00b7 re-auth: password + authenticator" + else -> "Revision retention, grace period, re-auth for destructive actions" } // ── System stats row ──────────────────────────────────────────────────────────