160 lines
6.1 KiB
Kotlin
160 lines
6.1 KiB
Kotlin
package dev.privacyllc.period.navigation
|
|
|
|
import androidx.annotation.StringRes
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.material.icons.Icons
|
|
import androidx.compose.material.icons.filled.CalendarMonth
|
|
import androidx.compose.material.icons.filled.Insights
|
|
import androidx.compose.material.icons.filled.Settings
|
|
import androidx.compose.material.icons.filled.Today
|
|
import androidx.compose.material.icons.filled.Circle
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.NavigationBar
|
|
import androidx.compose.material3.NavigationBarItem
|
|
import androidx.compose.material3.Scaffold
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.vector.ImageVector
|
|
import androidx.compose.ui.res.stringResource
|
|
import androidx.compose.ui.text.style.TextAlign
|
|
import androidx.compose.ui.tooling.preview.Preview
|
|
import androidx.navigation.NavDestination.Companion.hierarchy
|
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
|
import androidx.navigation.compose.NavHost
|
|
import androidx.navigation.compose.composable
|
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
|
import androidx.hilt.navigation.compose.hiltViewModel
|
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
import androidx.navigation.compose.rememberNavController
|
|
import dev.privacyllc.period.R
|
|
import dev.privacyllc.period.designsystem.PeriodTheme
|
|
import dev.privacyllc.period.feature.onboarding.OnboardingScreen
|
|
import dev.privacyllc.period.feature.today.TodayScreen
|
|
|
|
/**
|
|
* The four tabs from docs/planning/PRODUCT_PLAN.md §20.
|
|
*
|
|
* Four and only four. Every proposal to add a fifth is answered by
|
|
* docs/planning/PROJECT_PLAN.md's "deliberately not" list.
|
|
*/
|
|
enum class PeriodDestination(
|
|
val route: String,
|
|
@param:StringRes val labelRes: Int,
|
|
val icon: ImageVector,
|
|
) {
|
|
TODAY("today", R.string.tab_today, Icons.Filled.Today),
|
|
CALENDAR("calendar", R.string.tab_calendar, Icons.Filled.CalendarMonth),
|
|
INSIGHTS("insights", R.string.tab_insights, Icons.Filled.Insights),
|
|
SETTINGS("settings", R.string.tab_settings, Icons.Filled.Settings),
|
|
}
|
|
|
|
/**
|
|
* The root: onboarding, then the app.
|
|
*
|
|
* The decision comes from `UserPreferences.onboardingCompleted` and from
|
|
* nowhere else. A second source — "does any period exist?" — would look
|
|
* equivalent and is not: somebody who deletes all their data has not asked to be
|
|
* onboarded again, and somebody who abandoned onboarding halfway has.
|
|
*/
|
|
@Composable
|
|
fun PeriodRoot(viewModel: RootViewModel = hiltViewModel()) {
|
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
|
|
|
when (state) {
|
|
RootState.Loading -> Unit // one frame; a spinner here flashes and reads as jank
|
|
RootState.Onboarding -> OnboardingScreen(onFinished = viewModel::onboardingFinished)
|
|
RootState.Ready -> PeriodApp()
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun PeriodApp() {
|
|
val navController = rememberNavController()
|
|
val backStackEntry by navController.currentBackStackEntryAsState()
|
|
val currentDestination = backStackEntry?.destination
|
|
|
|
Scaffold(
|
|
bottomBar = {
|
|
NavigationBar {
|
|
PeriodDestination.entries.forEach { destination ->
|
|
val selected = currentDestination?.hierarchy?.any { it.route == destination.route } == true
|
|
NavigationBarItem(
|
|
selected = selected,
|
|
onClick = {
|
|
navController.navigate(destination.route) {
|
|
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
|
|
launchSingleTop = true
|
|
restoreState = true
|
|
}
|
|
},
|
|
icon = { Icon(destination.icon, contentDescription = null) },
|
|
label = { Text(stringResource(destination.labelRes)) },
|
|
)
|
|
}
|
|
}
|
|
},
|
|
) { innerPadding ->
|
|
NavHost(
|
|
navController = navController,
|
|
startDestination = PeriodDestination.TODAY.route,
|
|
modifier = Modifier.padding(innerPadding),
|
|
) {
|
|
composable(PeriodDestination.TODAY.route) { TodayScreen() }
|
|
|
|
// Calendar, Insights and Settings arrive in Batches 03 and 06. They
|
|
// say "not built yet" rather than showing a convincing mock.
|
|
PeriodDestination.entries
|
|
.filter { it != PeriodDestination.TODAY }
|
|
.forEach { destination ->
|
|
composable(destination.route) {
|
|
PlaceholderScreen(stringResource(destination.labelRes))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Skeleton only. Each of these is replaced by its real screen in Batch 03, and
|
|
* saying "not built yet" on the screen is deliberate — a convincing mock is how
|
|
* a screen comes to be believed finished.
|
|
*/
|
|
@Composable
|
|
private fun PlaceholderScreen(title: String) {
|
|
Column(
|
|
modifier = Modifier.fillMaxSize(),
|
|
verticalArrangement = Arrangement.Center,
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
) {
|
|
Icon(
|
|
Icons.Filled.Circle,
|
|
contentDescription = null,
|
|
tint = MaterialTheme.colorScheme.primaryContainer,
|
|
)
|
|
Text(
|
|
text = title,
|
|
style = MaterialTheme.typography.headlineMedium,
|
|
color = MaterialTheme.colorScheme.onBackground,
|
|
)
|
|
Text(
|
|
text = "Not built yet",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
textAlign = TextAlign.Center,
|
|
)
|
|
}
|
|
}
|
|
|
|
@Preview(showBackground = true)
|
|
@Composable
|
|
private fun PlaceholderPreview() {
|
|
PeriodTheme { PlaceholderScreen("Calendar") }
|
|
}
|