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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String?> = context.dataStore.data.map { it[KEY_BASE_URL] }
Expand All @@ -101,6 +106,11 @@ class PreferencesRepository @Inject constructor(
val historyPageSize: Flow<Int> = context.dataStore.data.map { it[KEY_HISTORY_PAGE_SIZE] ?: 50 }
val accountTimezone: Flow<String?> = context.dataStore.data.map { it[KEY_ACCOUNT_TIMEZONE] }
val timezoneOverride: Flow<String?> = 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<List<String>?> = 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) }
Expand Down Expand Up @@ -198,6 +208,15 @@ class PreferencesRepository @Inject constructor(
context.dataStore.edit { it[KEY_HISTORY_PAGE_SIZE] = size }
}

suspend fun saveNavPins(routes: List<String>) {
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)
Expand Down
43 changes: 21 additions & 22 deletions sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -161,30 +165,19 @@ 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<String> =
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<String> = drawerRoutes

// ── Root composable ───────────────────────────────────────────────────────────

@Composable
fun SheafApp(
pendingRedemption: PendingRedemptionHolder,
authViewModel: AuthViewModel = hiltViewModel(),
navPinsViewModel: NavPinsViewModel = hiltViewModel(),
) {
val isLoggedIn by authViewModel.isLoggedIn.collectAsState()
val pendingRedeem by pendingRedemption.pending.collectAsState()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
},
Expand All @@ -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() } },
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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() },
Expand Down
19 changes: 3 additions & 16 deletions sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,6 @@ fun HomeScreen(
onNavigateToSystemSafety: () -> Unit,
onNavigateToRetention: () -> Unit,
onNavigateToSettings: () -> Unit,
onNavigateToMessages: () -> Unit,
onNavigateToNotifications: () -> Unit,
viewModel: HomeViewModel = hiltViewModel(),
authViewModel: AuthViewModel = hiltViewModel(),
) {
Expand Down Expand Up @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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. */
Expand All @@ -46,6 +42,9 @@ data class DrawerGroup(
val items: List<DrawerDest>,
)

/** 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,
Expand All @@ -57,52 +56,100 @@ data class DrawerGroup(
val drawerGroups: List<DrawerGroup> = 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<DrawerDest> = drawerGroups.flatMap { it.items }

/** Every route the drawer can reach, for chrome / selection decisions. */
val drawerRoutes: Set<String> = drawerGroups.flatMap { group -> group.items.map { it.route } }.toSet()
val drawerRoutes: Set<String> = allDests.mapTo(mutableSetOf()) { it.route }

/** Everything the user may pin. Home is excluded: it owns the first slot. */
val pinnableDests: List<DrawerDest> = 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<String> = 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<String>?): List<DrawerDest> {
val byRoute = pinnableDests.associateBy { it.route }
val chosen = LinkedHashMap<String, DrawerDest>()
(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
Expand All @@ -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,
Expand Down
Loading
Loading