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/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 ────────────────────────────────────────────────────────── 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") } + } +}