Remove Scaffold from top level screens

pull/330/head
Don Turner 2 years ago
parent 3a106534e7
commit 828242dbad

@ -21,6 +21,7 @@ import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
@ -168,13 +169,13 @@ class NavigationTest {
// Verify that the top bar contains the app name on the first screen. // Verify that the top bar contains the app name on the first screen.
onNodeWithText(appName).assertExists() onNodeWithText(appName).assertExists()
// Go to the bookmarks tab, verify that the top bar contains the app name. // Go to the saved tab, verify that the top bar contains "saved". This means
// we'll have 2 elements with the text "saved" on screen. One in the top bar, and
// one in the bottom navigation.
onNodeWithText(saved).performClick() onNodeWithText(saved).performClick()
onNodeWithText(appName).assertExists() onAllNodesWithText(saved).assertCountEquals(2)
// Go to the interests tab, verify that the top bar contains "Interests". This means // As above but for the interests tab.
// we'll have 2 elements with the text "Interests" on screen. One in the top bar, and
// one in the bottom navigation.
onNodeWithText(interests).performClick() onNodeWithText(interests).performClick()
onAllNodesWithText(interests).assertCountEquals(2) onAllNodesWithText(interests).assertCountEquals(2)
} }
@ -214,7 +215,7 @@ class NavigationTest {
onNodeWithText(ok).performClick() onNodeWithText(ok).performClick()
// Check that the saved screen is still visible and selected. // Check that the saved screen is still visible and selected.
onNodeWithText(saved).assertIsSelected() onAllNodesWithText(saved).onLast().assertIsSelected()
} }
} }

@ -30,9 +30,19 @@ import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.createGraph import androidx.navigation.createGraph
import androidx.navigation.testing.TestNavHostController import androidx.navigation.testing.TestNavHostController
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -45,13 +55,33 @@ import org.junit.Test
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
class NiaAppStateTest { class NiaAppStateTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@get:Rule @get:Rule
val composeTestRule = createComposeRule() val composeTestRule = createComposeRule()
// Create the test dependencies.
private lateinit var testScope: TestScope
private val networkMonitor = TestNetworkMonitor()
// Subject under test.
private lateinit var state: NiaAppState private lateinit var state: NiaAppState
@Before
fun setup() {
// We use the Unconfined dispatcher to ensure that coroutines are executed sequentially in
// tests.
testScope = TestScope(UnconfinedTestDispatcher())
}
@After
fun cleanup() {
testScope.cancel()
}
@Test @Test
fun niaAppState_currentDestination() { fun niaAppState_currentDestination() = runTest {
var currentDestination: String? = null var currentDestination: String? = null
composeTestRule.setContent { composeTestRule.setContent {
@ -59,7 +89,9 @@ class NiaAppStateTest {
state = remember(navController) { state = remember(navController) {
NiaAppState( NiaAppState(
windowSizeClass = getCompactWindowClass(), windowSizeClass = getCompactWindowClass(),
navController = navController navController = navController,
networkMonitor = networkMonitor,
coroutineScope = testScope
) )
} }
@ -76,9 +108,12 @@ class NiaAppStateTest {
} }
@Test @Test
fun niaAppState_destinations() { fun niaAppState_destinations() = runTest {
composeTestRule.setContent { composeTestRule.setContent {
state = rememberNiaAppState(getCompactWindowClass()) state = rememberNiaAppState(
windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor
)
} }
assertEquals(3, state.topLevelDestinations.size) assertEquals(3, state.topLevelDestinations.size)
@ -93,7 +128,8 @@ class NiaAppStateTest {
val navController = rememberTestNavController() val navController = rememberTestNavController()
state = rememberNiaAppState( state = rememberNiaAppState(
windowSizeClass = getCompactWindowClass(), windowSizeClass = getCompactWindowClass(),
navController = navController navController = navController,
networkMonitor = networkMonitor
) )
// Do nothing - we should already be // Do nothing - we should already be
@ -101,11 +137,13 @@ class NiaAppStateTest {
} }
@Test @Test
fun niaAppState_showBottomBar_compact() { fun niaAppState_showBottomBar_compact() = runTest {
composeTestRule.setContent { composeTestRule.setContent {
state = NiaAppState( state = NiaAppState(
windowSizeClass = getCompactWindowClass(), windowSizeClass = getCompactWindowClass(),
navController = NavHostController(LocalContext.current) navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor,
coroutineScope = testScope
) )
} }
@ -114,11 +152,13 @@ class NiaAppStateTest {
} }
@Test @Test
fun niaAppState_showNavRail_medium() { fun niaAppState_showNavRail_medium() = runTest {
composeTestRule.setContent { composeTestRule.setContent {
state = NiaAppState( state = NiaAppState(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)), windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)),
navController = NavHostController(LocalContext.current) navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor,
coroutineScope = testScope
) )
} }
@ -127,11 +167,14 @@ class NiaAppStateTest {
} }
@Test @Test
fun niaAppState_showNavRail_large() { fun niaAppState_showNavRail_large() = runTest {
composeTestRule.setContent { composeTestRule.setContent {
state = NiaAppState( state = NiaAppState(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)), windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
navController = NavHostController(LocalContext.current) navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor,
coroutineScope = testScope
) )
} }
@ -139,6 +182,30 @@ class NiaAppStateTest {
assertFalse(state.shouldShowBottomBar) assertFalse(state.shouldShowBottomBar)
} }
@Test
fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest {
composeTestRule.setContent {
state = NiaAppState(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor,
coroutineScope = testScope
)
}
val collectJob = testScope.launch { state.isOffline.collect() }
networkMonitor.setConnected(false)
assertEquals(
true,
state.isOffline.value
)
collectJob.cancel()
}
private fun getCompactWindowClass() = WindowSizeClass.calculateFromSize(DpSize(500.dp, 300.dp)) private fun getCompactWindowClass() = WindowSizeClass.calculateFromSize(DpSize(500.dp, 300.dp))
} }

@ -37,6 +37,7 @@ import androidx.metrics.performance.JankStats
import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading
import com.google.samples.apps.nowinandroid.MainActivityUiState.Success import com.google.samples.apps.nowinandroid.MainActivityUiState.Success
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
@ -57,6 +58,9 @@ class MainActivity : ComponentActivity() {
@Inject @Inject
lateinit var lazyStats: dagger.Lazy<JankStats> lateinit var lazyStats: dagger.Lazy<JankStats>
@Inject
lateinit var networkMonitor: NetworkMonitor
val viewModel: MainActivityViewModel by viewModels() val viewModel: MainActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -105,6 +109,7 @@ class MainActivity : ComponentActivity() {
androidTheme = shouldUseAndroidTheme(uiState) androidTheme = shouldUseAndroidTheme(uiState)
) { ) {
NiaApp( NiaApp(
networkMonitor = networkMonitor,
windowSizeClass = calculateWindowSizeClass(this), windowSizeClass = calculateWindowSizeClass(this),
) )
} }

@ -31,10 +31,16 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration.Indefinite
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -42,8 +48,11 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavDestination.Companion.hierarchy
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBar import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBar
@ -61,12 +70,16 @@ import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
@OptIn( @OptIn(
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
ExperimentalLayoutApi::class, ExperimentalLayoutApi::class,
ExperimentalComposeUiApi::class ExperimentalComposeUiApi::class, ExperimentalLifecycleComposeApi::class
) )
@Composable @Composable
fun NiaApp( fun NiaApp(
windowSizeClass: WindowSizeClass, windowSizeClass: WindowSizeClass,
appState: NiaAppState = rememberNiaAppState(windowSizeClass) networkMonitor: NetworkMonitor,
appState: NiaAppState = rememberNiaAppState(
networkMonitor = networkMonitor,
windowSizeClass = windowSizeClass
),
) { ) {
val background: @Composable (@Composable () -> Unit) -> Unit = val background: @Composable (@Composable () -> Unit) -> Unit =
when (appState.currentDestination?.route) { when (appState.currentDestination?.route) {
@ -75,6 +88,9 @@ fun NiaApp(
} }
background { background {
val snackbarHostState = remember { SnackbarHostState() }
Scaffold( Scaffold(
modifier = Modifier.semantics { modifier = Modifier.semantics {
testTagsAsResourceId = true testTagsAsResourceId = true
@ -82,11 +98,14 @@ fun NiaApp(
containerColor = Color.Transparent, containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground, contentColor = MaterialTheme.colorScheme.onBackground,
contentWindowInsets = WindowInsets(0, 0, 0, 0), contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
val destination = appState.topLevelDestinations[appState.currentDestination?.route] // Show the top app bar on top level destinations.
if (appState.shouldShowTopBar && destination != null) { val topLevelDestination =
appState.topLevelDestinations[appState.currentDestination?.route]
if (topLevelDestination != null) {
NiaTopAppBar( NiaTopAppBar(
titleRes = destination.titleTextId, titleRes = topLevelDestination.titleTextId,
actionIcon = NiaIcons.Settings, actionIcon = NiaIcons.Settings,
actionIconContentDescription = stringResource( actionIconContentDescription = stringResource(
id = settingsR.string.top_app_bar_action_icon_description id = settingsR.string.top_app_bar_action_icon_description
@ -94,7 +113,7 @@ fun NiaApp(
colors = TopAppBarDefaults.centerAlignedTopAppBarColors( colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent containerColor = Color.Transparent
), ),
onActionClick = { /*openAccountDialog = true*/ } onActionClick = { appState.toggleSettingsDialog(true) }
) )
} }
}, },
@ -108,6 +127,24 @@ fun NiaApp(
} }
} }
) { padding -> ) { padding ->
val isOffline by appState.isOffline.collectAsStateWithLifecycle()
// If user is not connected to the internet show a snack bar to inform them.
val notConnected = stringResource(R.string.for_you_not_connected)
LaunchedEffect(isOffline) {
if (isOffline) snackbarHostState.showSnackbar(
message = notConnected,
duration = Indefinite
)
}
if (appState.shouldShowSettingsDialog) {
SettingsDialog(
onDismiss = { appState.toggleSettingsDialog(false) }
)
}
Row( Row(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@ -134,6 +171,9 @@ fun NiaApp(
.padding(padding) .padding(padding)
.consumedWindowInsets(padding) .consumedWindowInsets(padding)
) )
// TODO: We may want to add padding or spacer when the snackbar is shown so that
// content doesn't display behind it.
} }
} }
} }

@ -21,7 +21,11 @@ import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDestination import androidx.navigation.NavDestination
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
@ -30,6 +34,11 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions import androidx.navigation.navOptions
import androidx.tracing.trace import androidx.tracing.trace
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.navigation.NiaNavigationDestination
import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.navigateToBookmarks import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.navigateToBookmarks
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou
@ -38,29 +47,37 @@ import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@Composable @Composable
fun rememberNiaAppState( fun rememberNiaAppState(
windowSizeClass: WindowSizeClass, windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor,
coroutineScope: CoroutineScope = rememberCoroutineScope(),
navController: NavHostController = rememberNavController() navController: NavHostController = rememberNavController()
): NiaAppState { ): NiaAppState {
NavigationTrackingSideEffect(navController) NavigationTrackingSideEffect(navController)
return remember(navController, windowSizeClass) { return remember(navController, coroutineScope, windowSizeClass, networkMonitor) {
NiaAppState(navController, windowSizeClass) NiaAppState(navController, coroutineScope, windowSizeClass, networkMonitor)
} }
} }
@Stable @Stable
class NiaAppState( class NiaAppState(
val navController: NavHostController, val navController: NavHostController,
val windowSizeClass: WindowSizeClass val coroutineScope: CoroutineScope,
val windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor,
) { ) {
val currentDestination: NavDestination? val currentDestination: NavDestination?
@Composable get() = navController @Composable get() = navController
.currentBackStackEntryAsState().value?.destination .currentBackStackEntryAsState().value?.destination
val shouldShowTopBar: Boolean private var _shouldShowSettingsDialog by mutableStateOf(false)
@Composable get() = (currentDestination?.route in topLevelDestinations) val shouldShowSettingsDialog = _shouldShowSettingsDialog
val shouldShowBottomBar: Boolean val shouldShowBottomBar: Boolean
get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact || get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact ||
@ -69,6 +86,14 @@ class NiaAppState(
val shouldShowNavRail: Boolean val shouldShowNavRail: Boolean
get() = !shouldShowBottomBar get() = !shouldShowBottomBar
val isOffline = networkMonitor.isOnline
.map(Boolean::not)
.stateIn(
scope = coroutineScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false
)
/** /**
* Map of top level destinations to be used in the TopBar, BottomBar and NavRail. The key is the * Map of top level destinations to be used in the TopBar, BottomBar and NavRail. The key is the
* route. * route.
@ -109,6 +134,10 @@ class NiaAppState(
fun onBackClick() { fun onBackClick() {
navController.popBackStack() navController.popBackStack()
} }
fun toggleSettingsDialog(shouldShow: Boolean) {
_shouldShowSettingsDialog = shouldShow
}
} }
/** /**

@ -21,10 +21,8 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumedWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
@ -33,11 +31,9 @@ import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp

@ -22,21 +22,14 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumedWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridCells.Adaptive import androidx.compose.foundation.lazy.grid.GridCells.Adaptive
@ -51,19 +44,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration.Indefinite
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
@ -104,11 +91,9 @@ internal fun ForYouRoute(
) { ) {
val interestsSelectionState by viewModel.interestsSelectionUiState.collectAsStateWithLifecycle() val interestsSelectionState by viewModel.interestsSelectionUiState.collectAsStateWithLifecycle()
val feedState by viewModel.feedState.collectAsStateWithLifecycle() val feedState by viewModel.feedState.collectAsStateWithLifecycle()
val isOffline by viewModel.isOffline.collectAsStateWithLifecycle()
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle() val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
ForYouScreen( ForYouScreen(
isOffline = isOffline,
isSyncing = isSyncing, isSyncing = isSyncing,
interestsSelectionState = interestsSelectionState, interestsSelectionState = interestsSelectionState,
feedState = feedState, feedState = feedState,
@ -120,7 +105,6 @@ internal fun ForYouRoute(
) )
} }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
internal fun ForYouScreen( internal fun ForYouScreen(
isOffline: Boolean, isOffline: Boolean,
@ -133,13 +117,7 @@ internal fun ForYouScreen(
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = Color.Transparent,
contentWindowInsets = WindowInsets(0, 0, 0, 0)
) { innerPadding ->
// Workaround to call Activity.reportFullyDrawn from Jetpack Compose. // Workaround to call Activity.reportFullyDrawn from Jetpack Compose.
// This code should be called when the UI is ready for use // This code should be called when the UI is ready for use
// and relates to Time To Full Display. // and relates to Time To Full Display.
@ -164,22 +142,12 @@ internal fun ForYouScreen(
val state = rememberLazyGridState() val state = rememberLazyGridState()
TrackScrollJank(scrollableState = state, stateName = "forYou:feed") TrackScrollJank(scrollableState = state, stateName = "forYou:feed")
val notConnected = stringResource(R.string.for_you_not_connected)
LaunchedEffect(isOffline) {
if (isOffline) snackbarHostState.showSnackbar(
message = notConnected,
duration = Indefinite
)
}
LazyVerticalGrid( LazyVerticalGrid(
columns = Adaptive(300.dp), columns = Adaptive(300.dp),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = modifier modifier = modifier
.padding(innerPadding)
.consumedWindowInsets(innerPadding)
.fillMaxSize() .fillMaxSize()
.testTag("forYou:feed"), .testTag("forYou:feed"),
state = state state = state
@ -208,14 +176,15 @@ internal fun ForYouScreen(
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
) )
item(span = { GridItemSpan(maxLineSpan) }) { /*item(span = { GridItemSpan(maxLineSpan) }) {
Column { Column {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Add space for the content to clear the "offline" snackbar. // Add space for the content to clear the "offline" snackbar.
// TODO: Check that the Scaffold handles this correctly in NiaApp
if (isOffline) Spacer(modifier = Modifier.height(48.dp)) if (isOffline) Spacer(modifier = Modifier.height(48.dp))
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
} }
} }*/
} }
AnimatedVisibility( AnimatedVisibility(
visible = isSyncing || visible = isSyncing ||
@ -224,10 +193,7 @@ internal fun ForYouScreen(
) { ) {
val loadingContentDescription = stringResource(id = R.string.for_you_loading) val loadingContentDescription = stringResource(id = R.string.for_you_loading)
Box( Box(
modifier = Modifier modifier = Modifier.fillMaxWidth()
.padding(innerPadding)
.consumedWindowInsets(innerPadding)
.fillMaxWidth()
) { ) {
NiaOverlayLoadingWheel( NiaOverlayLoadingWheel(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
@ -235,7 +201,6 @@ internal fun ForYouScreen(
) )
} }
} }
}
} }
/** /**
@ -426,7 +391,6 @@ fun ForYouScreenPopulatedFeed() {
BoxWithConstraints { BoxWithConstraints {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
isOffline = false,
isSyncing = false, isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
@ -449,7 +413,6 @@ fun ForYouScreenOfflinePopulatedFeed() {
BoxWithConstraints { BoxWithConstraints {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
isOffline = true,
isSyncing = false, isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
@ -472,7 +435,6 @@ fun ForYouScreenTopicSelection() {
BoxWithConstraints { BoxWithConstraints {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
isOffline = false,
isSyncing = false, isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.WithInterestsSelection( interestsSelectionState = ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = previewTopics.map { FollowableTopic(it, false) }, topics = previewTopics.map { FollowableTopic(it, false) },
@ -498,7 +460,6 @@ fun ForYouScreenLoading() {
BoxWithConstraints { BoxWithConstraints {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
isOffline = false,
isSyncing = false, isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.Loading, interestsSelectionState = ForYouInterestsSelectionUiState.Loading,
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
@ -517,7 +478,6 @@ fun ForYouScreenPopulatedAndLoading() {
BoxWithConstraints { BoxWithConstraints {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
isOffline = false,
isSyncing = true, isSyncing = true,
interestsSelectionState = ForYouInterestsSelectionUiState.Loading, interestsSelectionState = ForYouInterestsSelectionUiState.Loading,
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(

@ -94,14 +94,6 @@ class ForYouViewModel @Inject constructor(
mutableStateOf<Set<String>>(emptySet()) mutableStateOf<Set<String>>(emptySet())
} }
val isOffline = networkMonitor.isOnline
.map(Boolean::not)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false
)
val isSyncing = syncStatusMonitor.isSyncing val isSyncing = syncStatusMonitor.isSyncing
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,

@ -1404,21 +1404,6 @@ class ForYouViewModelTest {
collectJob1.cancel() collectJob1.cancel()
collectJob2.cancel() collectJob2.cancel()
} }
@Test
fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest {
val collectJob =
launch(UnconfinedTestDispatcher()) { viewModel.isOffline.collect() }
networkMonitor.setConnected(false)
assertEquals(
true,
viewModel.isOffline.value
)
collectJob.cancel()
}
} }
private val sampleAuthors = listOf( private val sampleAuthors = listOf(

@ -61,9 +61,9 @@ import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Suc
@ExperimentalLifecycleComposeApi @ExperimentalLifecycleComposeApi
@Composable @Composable
internal fun SettingsDialog( fun SettingsDialog(
viewModel: SettingsViewModel = hiltViewModel(), onDismiss: () -> Unit,
onDismiss: () -> Unit viewModel: SettingsViewModel = hiltViewModel()
) { ) {
val settingsUiState by viewModel.settingsUiState.collectAsStateWithLifecycle() val settingsUiState by viewModel.settingsUiState.collectAsStateWithLifecycle()
SettingsDialog( SettingsDialog(
@ -132,7 +132,7 @@ private fun SettingsPanel(
onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit
) { ) {
SettingsDialogSectionTitle(text = stringResource(R.string.theme)) SettingsDialogSectionTitle(text = stringResource(R.string.theme))
Column { Column(Modifier.selectableGroup()) {
SettingsDialogThemeChooserRow( SettingsDialogThemeChooserRow(
text = stringResource(R.string.brand_default), text = stringResource(R.string.brand_default),
selected = settings.brand == DEFAULT, selected = settings.brand == DEFAULT,

@ -47,7 +47,12 @@ class SettingsViewModel @Inject constructor(
} }
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), // Starting eagerly means the user data is ready when the SettingsDialog is laid out
// for the first time. Without this the layout is done using the "Loading" text,
// then replaced with the user editable fields once loaded, however, the layout
// height doesn't change meaning all the fields are squashed into a small
// scrollable column.
started = SharingStarted.Eagerly,
initialValue = Loading initialValue = Loading
) )

Loading…
Cancel
Save