From a6a7f0413a12de4434ff0696e95246d4aa65544b Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:26:21 -0400 Subject: [PATCH] feat(ui): navigation drawer holding every destination Features had drifted into Settings because the bottom bar only fits about five items: Analytics sat inside the History screen, Reminders under Settings > Notifications, Relationships under Settings > System, Files under Settings > Data. Three-plus taps for things the web client puts one click away. Adds a ModalNavigationDrawer listing every destination, grouped, mirroring web's sidebar. The bar becomes the fast path rather than the whole app: Home plus three destinations plus a "More" entry that opens the drawer. Polls moves off the bar into the drawer. The bar/rail now stays on screen for drawer destinations too, with "More" reading as selected, so stepping to one doesn't strand you with no chrome. opens the drawer only where the bar is showing, so detail screens, editors and the pan/zoom relationship graph keep their horizontal drags. Sign out stays in Groups gets a drawer destination too, which turned up a duplicate: the standalone GroupsScreen behind Routes.GROUPS had been unreachable since groups became a tab of the People screen, and the live tab had drifted to a plainer card. The dead screen was the better one, so its card (nesting indent, expand-to-see-members, edit affordance) is now what the tab renders, the flat duplicate is gone, and Routes.GROUPS opens the People screen on its Groups tab. --- .../java/systems/lupine/sheaf/ui/SheafApp.kt | 81 ++++++++-- .../lupine/sheaf/ui/groups/GroupsScreen.kt | 94 ++--------- .../lupine/sheaf/ui/navigation/AppDrawer.kt | 149 ++++++++++++++++++ .../lupine/sheaf/ui/people/PeopleScreen.kt | 75 ++++----- 4 files changed, 258 insertions(+), 141 deletions(-) create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/navigation/AppDrawer.kt 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 c72e3c0..b3ceeb3 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 @@ -33,12 +33,13 @@ import systems.lupine.sheaf.ui.components.LocalDisplayTimeZone import systems.lupine.sheaf.ui.components.LocalFileCdnBase import systems.lupine.sheaf.ui.debug.DebugScreen import systems.lupine.sheaf.ui.groups.GroupDetailScreen -import systems.lupine.sheaf.ui.groups.GroupsScreen import systems.lupine.sheaf.ui.analytics.AnalyticsScreen import systems.lupine.sheaf.ui.history.HistoryScreen 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.drawerRoutes import systems.lupine.sheaf.ui.members.MemberDetailScreen import systems.lupine.sheaf.ui.members.MemberProfileScreen import systems.lupine.sheaf.ui.members.MembersScreen @@ -66,6 +67,7 @@ import systems.lupine.sheaf.ui.admin.AdminPanelScreen import systems.lupine.sheaf.ui.settings.SettingsScreen import systems.lupine.sheaf.ui.settings.SystemEditScreen import systems.lupine.sheaf.ui.settings.SystemSafetyScreen +import kotlinx.coroutines.launch // ── Route constants ─────────────────────────────────────────────────────────── @@ -159,14 +161,24 @@ 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), - TopLevelDest(Routes.POLLS, "Polls", Icons.Filled.HowToVote, Icons.Outlined.HowToVote), ) +// 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 + // ── Root composable ─────────────────────────────────────────────────────────── @Composable @@ -216,7 +228,7 @@ fun SheafApp( // Decide whether to show the bottom bar based on current destination val navBackStack by navController.currentBackStackEntryAsState() val currentRoute = navBackStack?.destination?.route - val showBottomBar = currentRoute in topLevelDestinations.map { it.route } + val showBottomBar = currentRoute in chromeRoutes // Provide the file CDN base (image hosted/external classification) and the // resolved display timezone (timestamp rendering) app-wide. @@ -235,6 +247,36 @@ fun SheafApp( wide -> NavigationSuiteType.NavigationRail else -> NavigationSuiteType.NavigationBar } + + val drawerState = rememberDrawerState(DrawerValue.Closed) + val scope = rememberCoroutineScope() + // Switching top-level destination is a "start over here" move, not a step + // deeper, so drawer and bar navigation share the same options: reset to the + // graph start, keep each destination's own scroll/selection state. + val goTo: (String) -> Unit = { route -> + navController.navigate(route) { + popUpTo(navController.graph.findStartDestination().id) { saveState = true } + launchSingleTop = true + restoreState = true + } + } + // Edge-swipe opens the drawer only where the bar/rail is showing. On detail + // screens, editors and the pan/zoom graph, a horizontal drag belongs to the + // content; a swipe that is already dragging the drawer open can always + // close it again. + ModalNavigationDrawer( + drawerState = drawerState, + gesturesEnabled = drawerState.isOpen || showBottomBar, + drawerContent = { + AppDrawerContent( + currentRoute = currentRoute, + onNavigate = { route -> + scope.launch { drawerState.close() } + goTo(route) + }, + ) + }, + ) { NavigationSuiteScaffold( layoutType = navSuiteType, navigationSuiteItems = { @@ -242,15 +284,7 @@ fun SheafApp( val selected = currentDest?.hierarchy?.any { it.route == dest.route } == true item( selected = selected, - onClick = { - navController.navigate(dest.route) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - }, + onClick = { goTo(dest.route) }, icon = { Icon( if (selected) dest.selectedIcon else dest.unselectedIcon, @@ -260,6 +294,18 @@ fun SheafApp( label = { Text(dest.label) }, ) } + // Overflow entry. Reads as selected whenever the current screen is + // a drawer destination that has no slot of its own, so the chrome + // still shows where you are. + val onDrawerDest = currentRoute != null && + currentRoute in drawerRoutes && + topLevelDestinations.none { it.route == currentRoute } + item( + selected = onDrawerDest, + onClick = { scope.launch { drawerState.open() } }, + icon = { Icon(Icons.Filled.Menu, contentDescription = "More") }, + label = { Text("More") }, + ) }, ) { // Cap content width on wide windows so forms and lists don't stretch @@ -344,12 +390,20 @@ fun SheafApp( val memberId = backStack.arguments?.getString("memberId") ?: return@composable MemberDetailScreen(memberId = memberId, onNavigateUp = { navController.navigateUp() }) } + // Same screen as PEOPLE, opened on its Groups tab. Groups is a tab + // rather than a screen of its own, but it still earns a drawer + // destination, and landing on Members after tapping Groups would be + // its own small betrayal. composable(Routes.GROUPS) { - GroupsScreen( + PeopleScreen( + onMemberClick = { id -> + navController.navigate(if (id == "new") "members/new" else "members/$id") + }, onGroupClick = { id -> navController.navigate(if (id == "new") "groups/new" else "groups/$id") }, onNavigateToSettings = { navController.navigate(Routes.SETTINGS) }, + startOnGroups = true, ) } composable(Routes.GROUP_DETAIL) { backStack -> @@ -713,4 +767,5 @@ fun SheafApp( } } } + } } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsScreen.kt index 8907110..8be57ac 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsScreen.kt @@ -26,92 +26,16 @@ import systems.lupine.sheaf.ui.components.* import systems.lupine.sheaf.ui.relationships.REL_SCOPE_GROUP import systems.lupine.sheaf.ui.relationships.RelationshipsEditor -// ── Groups list ─────────────────────────────────────────────────────────────── +// ── Group list card ─────────────────────────────────────────────────────────── -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable -fun GroupsScreen( - onGroupClick: (String) -> Unit, - onNavigateToSettings: () -> Unit, - viewModel: GroupsViewModel = hiltViewModel(), -) { - val state by viewModel.state.collectAsState() - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() - - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) viewModel.load() - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } - - Scaffold( - contentWindowInsets = WindowInsets(0), - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - SheafLargeFlexibleTopAppBar( - title = { Text("Groups") }, - scrollBehavior = scrollBehavior, - actions = { - IconButton(onClick = onNavigateToSettings) { - Icon(Icons.Default.Settings, contentDescription = "Settings") - } - }, - ) - }, - floatingActionButton = { - FloatingActionButton(onClick = { onGroupClick("new") }) { - Icon(Icons.Default.Add, contentDescription = "Add group") - } - }, - ) { padding -> - when { - state.isLoading -> Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - state.error != null -> Column( - Modifier.fillMaxSize().padding(padding).padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - ErrorBanner(state.error!!) - Button(onClick = { viewModel.load() }) { Text("Retry") } - } - state.groups.isEmpty() -> EmptyState( - icon = Icons.Default.FolderOpen, - title = "No groups yet", - subtitle = "Tap + to create your first group.", - modifier = Modifier.fillMaxSize().padding(padding), - ) - else -> LazyColumn( - contentPadding = PaddingValues( - start = 16.dp, end = 16.dp, - top = padding.calculateTopPadding() + 8.dp, - bottom = padding.calculateBottomPadding() + 80.dp, - ), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - val ordered = orderGroupsHierarchically(state.groups) - items(ordered, key = { it.first.id }) { (group, depth) -> - GroupCard( - group = group, - depth = depth, - expanded = group.id in state.expanded, - members = state.groupMembers[group.id], - loading = group.id in state.loadingMembers, - error = state.memberLoadErrors[group.id], - onToggleExpand = { viewModel.toggleExpand(group.id) }, - onEdit = { onGroupClick(group.id) }, - ) - } - } - } - } -} - -@Composable -private fun GroupCard( +/** + * A group row: indented by nesting depth, tap to expand its members inline, + * with an edit affordance. Lives here next to the group detail screen but is + * rendered by the Groups tab of the People screen, which is the only place + * groups are listed. + */ +internal fun GroupCard( group: systems.lupine.sheaf.data.model.GroupRead, depth: Int, expanded: Boolean, @@ -523,7 +447,7 @@ private fun collectDescendants( * with no parent (or a parent that isn't in the set); orphans fall back to * roots so nothing is dropped. */ -private fun orderGroupsHierarchically( +internal fun orderGroupsHierarchically( groups: List, ): List> { val byId = groups.associateBy { it.id } 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 new file mode 100644 index 0000000..d6663d9 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/navigation/AppDrawer.kt @@ -0,0 +1,149 @@ +package systems.lupine.sheaf.ui.navigation + +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.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.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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. */ +data class DrawerDest( + val route: String, + val label: String, + val icon: ImageVector, +) + +/** A titled cluster of drawer rows. A null title renders with no header. */ +data class DrawerGroup( + val title: String?, + val items: List, +) + +/** + * 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, + * so every feature has to be reachable from here. + * + * Sign out is deliberately absent. A destructive, session-ending action one + * stray swipe from any screen is a footgun; it stays in Settings > Danger zone. + */ +val drawerGroups: List = listOf( + DrawerGroup( + title = null, + items = listOf( + DrawerDest(Routes.HOME, "Home", Icons.Outlined.Home), + ), + ), + 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), + ), + ), + DrawerGroup( + title = "Writing", + items = listOf( + DrawerDest(Routes.JOURNALS, "Journals", Icons.AutoMirrored.Outlined.MenuBook), + DrawerDest(Routes.MESSAGES, "Board messages", Icons.Outlined.Forum), + ), + ), + DrawerGroup( + title = "Engage", + items = listOf( + DrawerDest(Routes.POLLS, "Polls", Icons.Outlined.HowToVote), + DrawerDest(Routes.NOTIFICATIONS_REMINDERS, "Reminders", Icons.Outlined.Alarm), + ), + ), + DrawerGroup( + title = "System", + items = listOf( + DrawerDest(Routes.RELATIONSHIPS, "Relationships", Icons.Outlined.Hub), + DrawerDest(Routes.FILES, "Files", Icons.Outlined.Folder), + ), + ), + DrawerGroup( + title = null, + items = listOf( + DrawerDest(Routes.SETTINGS_NOTIFICATIONS, "Notifications", Icons.Outlined.Notifications), + DrawerDest(Routes.SUPPORT, "Support", Icons.Outlined.HelpOutline), + DrawerDest(Routes.SETTINGS, "Settings", Icons.Outlined.Settings), + ), + ), +) + +/** Every route the drawer can reach, for chrome / selection decisions. */ +val drawerRoutes: Set = drawerGroups.flatMap { group -> group.items.map { it.route } }.toSet() + +/** + * Drawer body: the grouped destination list. Scrolls, because the list is + * longer than a short phone in landscape. + */ +@Composable +fun AppDrawerContent( + currentRoute: String?, + onNavigate: (String) -> Unit, +) { + ModalDrawerSheet { + androidx.compose.foundation.layout.Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + Text( + text = "Sheaf", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(start = 28.dp, top = 20.dp, bottom = 12.dp), + ) + drawerGroups.forEachIndexed { index, group -> + if (group.title != null) { + Text( + text = group.title, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 28.dp, top = 12.dp, bottom = 4.dp), + ) + } else if (index > 0) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + group.items.forEach { dest -> + NavigationDrawerItem( + selected = currentRoute == dest.route, + onClick = { onNavigate(dest.route) }, + icon = { Icon(dest.icon, contentDescription = null) }, + label = { Text(dest.label) }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), + ) + } + } + Spacer(Modifier.height(12.dp)) + } + } +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/people/PeopleScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/people/PeopleScreen.kt index 0254688..8651fd8 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/people/PeopleScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/people/PeopleScreen.kt @@ -30,7 +30,9 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import systems.lupine.sheaf.data.model.GroupRead import systems.lupine.sheaf.data.model.MemberRead import systems.lupine.sheaf.ui.components.* +import systems.lupine.sheaf.ui.groups.GroupCard import systems.lupine.sheaf.ui.groups.GroupsViewModel +import systems.lupine.sheaf.ui.groups.orderGroupsHierarchically import systems.lupine.sheaf.ui.members.MembersViewModel private enum class PeopleTab { MEMBERS, GROUPS } @@ -41,13 +43,17 @@ fun PeopleScreen( onMemberClick: (String) -> Unit, onGroupClick: (String) -> Unit, onNavigateToSettings: () -> Unit, + /** Open on the Groups tab, for the drawer's Groups destination. */ + startOnGroups: Boolean = false, membersViewModel: MembersViewModel = hiltViewModel(), groupsViewModel: GroupsViewModel = hiltViewModel(), ) { val membersState by membersViewModel.state.collectAsState() val groupsState by groupsViewModel.state.collectAsState() - var tab by rememberSaveable { mutableStateOf(PeopleTab.MEMBERS) } + var tab by rememberSaveable { + mutableStateOf(if (startOnGroups) PeopleTab.GROUPS else PeopleTab.MEMBERS) + } var memberQuery by rememberSaveable { mutableStateOf("") } var groupQuery by rememberSaveable { mutableStateOf("") } var searchOpen by rememberSaveable { mutableStateOf(false) } @@ -176,6 +182,11 @@ fun PeopleScreen( showSearch = searchOpen, onRetry = { groupsViewModel.load() }, onGroupClick = onGroupClick, + expanded = groupsState.expanded, + groupMembers = groupsState.groupMembers, + loadingMembers = groupsState.loadingMembers, + memberLoadErrors = groupsState.memberLoadErrors, + onToggleExpand = { groupsViewModel.toggleExpand(it) }, ) } } @@ -256,6 +267,11 @@ private fun GroupsTabBody( showSearch: Boolean, onRetry: () -> Unit, onGroupClick: (String) -> Unit, + expanded: Set, + groupMembers: Map>, + loadingMembers: Set, + memberLoadErrors: Map, + onToggleExpand: (String) -> Unit, ) { when { isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -300,8 +316,21 @@ private fun GroupsTabBody( contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 88.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - items(groups, key = { it.id }) { group -> - GroupCard(group = group, onClick = { onGroupClick(group.id) }) + // Subgroups sit indented under their parent. Filtering by + // search can strand a child whose parent didn't match; the + // ordering treats those as roots so nothing is dropped. + val ordered = orderGroupsHierarchically(groups) + items(ordered, key = { it.first.id }) { (group, depth) -> + GroupCard( + group = group, + depth = depth, + expanded = group.id in expanded, + members = groupMembers[group.id], + loading = group.id in loadingMembers, + error = memberLoadErrors[group.id], + onToggleExpand = { onToggleExpand(group.id) }, + onEdit = { onGroupClick(group.id) }, + ) } } } @@ -339,43 +368,3 @@ private fun MemberCard(member: MemberRead, onClick: () -> Unit) { } } } - -@Composable -private fun GroupCard(group: GroupRead, onClick: () -> Unit) { - val accent = parseColor(group.color ?: "#534AB7") ?: MaterialTheme.colorScheme.primary - Card( - onClick = onClick, - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - ) { - Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { - Box( - Modifier - .size(40.dp) - .clip(MaterialTheme.shapes.medium) - .background(accent.copy(alpha = 0.2f)), - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Default.Folder, contentDescription = null, tint = accent, modifier = Modifier.size(22.dp)) - } - Column(modifier = Modifier.weight(1f).padding(start = 14.dp)) { - Text( - group.name, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (!group.description.isNullOrBlank()) { - Text( - group.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } - } -}