Privacy-Period-Tracker/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt

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