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, - ) - } - } - } - } -}